├── .github └── workflows │ └── ci.yml ├── .prettierignore ├── LICENSE.md ├── README.md ├── composer.json ├── package-lock.json ├── package.json └── src ├── Exceptions ├── FieldNotFoundException.php ├── InvalidValueException.php └── JsConnectException.php ├── JsConnect.php ├── JsConnectJSONP.php ├── JsConnectServer.php └── functions.compat.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | # Concurrency is only limited on pull requests. head_ref is only defined on PR triggers so otherwise it will use the random run id and always build all pushes. 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | phpunit-tests: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | php-version: ["7.4", "8.0", "8.2"] 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Installing PHP ${{ matrix.php-version }} 27 | uses: shivammathur/setup-php@v2 28 | with: 29 | php-version: ${{ matrix.php-version }} 30 | - name: Composer Install 31 | run: composer install -o 32 | - name: PHPUnit 33 | run: ./vendor/bin/phpunit -c ./phpunit.xml.dist 34 | prettier: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: actions/setup-node@v3 39 | - name: "NPM Install" 40 | run: npm ci 41 | - name: Check Prettier 42 | run: npm run prettier:check 43 | static-analysis: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v3 47 | - name: Installing PHP 8.2 48 | uses: shivammathur/setup-php@v2 49 | with: 50 | php-version: 8.2 51 | - name: Composer Install 52 | run: composer install -o 53 | - name: Check psalm 54 | run: ./vendor/bin/psalm 55 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # vendor files 2 | node_modules 3 | vendor 4 | artisan 5 | _ide_helper* 6 | *.lock 7 | package-lock.json 8 | 9 | # App writeable directories 10 | bootstrap/cache 11 | cache 12 | storage 13 | dist 14 | build-config.php 15 | LICENSE.md 16 | README.md 17 | tests/tests.json 18 | 19 | # IDE Config 20 | .vscode 21 | .idea 22 | 23 | # Test output 24 | .phpunit.result.cache 25 | 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | =============== 3 | 4 | Copyright (c) 2011-2020 Vanilla Forums Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vanilla jsConnect Client Library for PHP # 2 | 3 | _**Note:** Vanilla has recently updated it's jsConnect protocol to a different architecture that will work with current browsers that block third party cookies. Make sure you update your libraries to use the protocol. Once you've done this you will need to configure Vanilla to use the protocol in your dashboard under jsConnect settings._ 4 | 5 | ## About jsConnect 6 | 7 | The jsConnect protocol is a simple single sign on (SSO) framework that allows you to easily use your own site to sign on to a Vanilla site. It is intended to require as little programming as possible. You will need to do the following: 8 | 9 | 1. Program one page that responds with information about the currently signed in user. 10 | 2. Your main sign in page should be capable of redirecting to a URL that is supplied in the querystring. 11 | 3. You can optionally provide a registration page too, but it must also be capable of redirecting via a query string parameter. 12 | 13 | ## Installation 14 | 15 | There are two ways to install jsConnect. 16 | 17 | 1. You can install this library via composer. You want to require `vanilla/js-connect-php`. 18 | 2. You can use the supplied [functions.jsconnect.php](./dist/functions.jsconnect.php). This is the old way of installing Vanilla. It still works, but we recommend transitioning to the composer install. 19 | 20 | ## Usage 21 | 22 | There are two ways to use this jsConnect library. There is an object oriented way and a functional way. 23 | 24 | ### Object Oriented Usage 25 | 26 | If you are new to jsConnect then we recommend the object oriented usage. Here is an example of what your page might look like. 27 | 28 | ```php 29 | $jsConnect = new \Vanilla\JsConnect\JsConnect(); 30 | 31 | // 1. Add your client ID and secret. These values are defined in your dashboard. 32 | $jsConnect->setSigningCredentials($clientID, $secret); 33 | 34 | // 2. Grab the current user from your session management system or database here. 35 | $signedIn = true; // this is just a placeholder 36 | 37 | // YOUR CODE HERE. 38 | 39 | // 3. Fill in the user information in a way that Vanilla can understand. 40 | if ($signedIn) { 41 | // CHANGE THESE FOUR LINES. 42 | $jsConnect 43 | ->setUniqueID('123') 44 | ->setName('Username') 45 | ->setEmail('user@example.com') 46 | ->setPhotoUrl('https://example.com/avatar.jpg'); 47 | } else { 48 | $jsConnect->setGuest(true); 49 | } 50 | 51 | // 4. Generate the jsConnect response and redirect. 52 | $jsConnect->handleRequest($_GET); 53 | 54 | ``` 55 | 56 | ## Functional Usage 57 | 58 | The functional usage is mainly for backwards compatibility. If you are currently using this method then you can continue to do so. However, you may want to port your code to the object oriented method when you have time. 59 | 60 | Here is an example of the functional usage: 61 | 62 | ```php 63 | // 1. Get your client ID and secret here. These must match those in your jsConnect settings. 64 | $clientID = "1234"; 65 | $secret = "1234"; 66 | 67 | // 2. Grab the current user from your session management system or database here. 68 | $signedIn = true; // this is just a placeholder 69 | 70 | // YOUR CODE HERE. 71 | 72 | // 3. Fill in the user information in a way that Vanilla can understand. 73 | $user = array(); 74 | 75 | if ($signedIn) { 76 | // CHANGE THESE FOUR LINES. 77 | $user['uniqueid'] = '123'; 78 | $user['name'] = 'John PHP'; 79 | $user['email'] = 'john.php@example.com'; 80 | $user['photourl'] = ''; 81 | } 82 | 83 | // 4. Generate the jsConnect string. 84 | 85 | // This should be true unless you are testing. 86 | // You can also use a hash name like md5, sha1 etc which must be the name as the connection settings in Vanilla. 87 | $secure = true; 88 | writeJsConnect($user, $_GET, $clientID, $secret, $secure); 89 | ``` 90 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanilla/js-connect-php", 3 | "description": "Client library for Vanilla's jsConnect SSO system.", 4 | "type": "library", 5 | "require": { 6 | "php": ">=7.4", 7 | "firebase/php-jwt": "^6.4", 8 | "ext-json": "*" 9 | }, 10 | "require-dev": { 11 | "phpunit/phpunit": "~9.0", 12 | "classpreloader/classpreloader": "^4.0", 13 | "classpreloader/console": "^3.0", 14 | "vanilla/standards": "^1.3", 15 | "vimeo/psalm": "^3.10" 16 | }, 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Todd Burry", 21 | "email": "todd@vanillaforums.com" 22 | } 23 | ], 24 | "autoload": { 25 | "files": [ 26 | "src/functions.compat.php" 27 | ], 28 | "psr-4": { 29 | "Vanilla\\JsConnect\\": "src" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Vanilla\\JsConnect\\Tests\\": "tests" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "@prettier/plugin-php": { 6 | "version": "0.19.4", 7 | "resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.19.4.tgz", 8 | "integrity": "sha512-FiSnSfP+Vo0/HVRXg7ZnEYJEM1eWS+MmsozYtzEdIf8Vg9v/+fwvyXMNayI5SgZ1Y9F5LGhl/EOMWIPzr9c2Xg==", 9 | "dev": true, 10 | "requires": { 11 | "linguist-languages": "^7.21.0", 12 | "mem": "^8.0.0", 13 | "php-parser": "^3.1.3" 14 | } 15 | }, 16 | "linguist-languages": { 17 | "version": "7.21.0", 18 | "resolved": "https://registry.npmjs.org/linguist-languages/-/linguist-languages-7.21.0.tgz", 19 | "integrity": "sha512-KrWJJbFOvlDhjlt5OhUipVlXg+plUfRurICAyij1ZVxQcqPt/zeReb9KiUVdGUwwhS/2KS9h3TbyfYLA5MDlxQ==", 20 | "dev": true 21 | }, 22 | "map-age-cleaner": { 23 | "version": "0.1.3", 24 | "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", 25 | "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", 26 | "dev": true, 27 | "requires": { 28 | "p-defer": "^1.0.0" 29 | } 30 | }, 31 | "mem": { 32 | "version": "8.1.1", 33 | "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz", 34 | "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==", 35 | "dev": true, 36 | "requires": { 37 | "map-age-cleaner": "^0.1.3", 38 | "mimic-fn": "^3.1.0" 39 | } 40 | }, 41 | "mimic-fn": { 42 | "version": "3.1.0", 43 | "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", 44 | "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", 45 | "dev": true 46 | }, 47 | "p-defer": { 48 | "version": "1.0.0", 49 | "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", 50 | "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", 51 | "dev": true 52 | }, 53 | "php-parser": { 54 | "version": "3.1.4", 55 | "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.1.4.tgz", 56 | "integrity": "sha512-WUEfH4FWsVItqgOknM67msDdcUAfgPJsHhPNl6EPXzWtX+PfdY282m4i8YIJ9ALUEhf+qGDajdmW+VYqSd7Deg==", 57 | "dev": true 58 | }, 59 | "prettier": { 60 | "version": "2.8.6", 61 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.6.tgz", 62 | "integrity": "sha512-mtuzdiBbHwPEgl7NxWlqOkithPyp4VN93V7VeHVWBF+ad3I5avc0RVDT4oImXQy9H/AqxA2NSQH8pSxHW6FYbQ==", 63 | "dev": true 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "prettier:check": "prettier --check .", 5 | "prettier:write": "prettier --write ." 6 | }, 7 | "devDependencies": { 8 | "@prettier/plugin-php": "^0.19.3", 9 | "prettier": "^2.8.4" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Exceptions/FieldNotFoundException.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2020 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Vanilla\JsConnect\Exceptions; 9 | 10 | /** 11 | * An exception that represents a missing field in a request or response. 12 | */ 13 | class FieldNotFoundException extends JsConnectException 14 | { 15 | /** 16 | * FieldNotFoundException constructor. 17 | * 18 | * @param string $field 19 | * @param string $collection 20 | */ 21 | public function __construct(string $field, string $collection = "payload") 22 | { 23 | parent::__construct("Missing field: {$collection}[{$field}]", 404); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidValueException.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2020 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Vanilla\JsConnect\Exceptions; 9 | 10 | /** 11 | * An exception that represents a value that is not the correct type or expected value. 12 | */ 13 | class InvalidValueException extends JsConnectException 14 | { 15 | /** 16 | * InvalidValueException constructor. 17 | * 18 | * @param string $message 19 | */ 20 | public function __construct(string $message = "") 21 | { 22 | parent::__construct($message, 400); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exceptions/JsConnectException.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2020 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Vanilla\JsConnect\Exceptions; 9 | 10 | /** 11 | * The base class for all JsConnect exceptions. 12 | */ 13 | class JsConnectException extends \Exception 14 | { 15 | } 16 | -------------------------------------------------------------------------------- /src/JsConnect.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2020 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Vanilla\JsConnect; 9 | 10 | use Exception; 11 | use Firebase\JWT\JWT; 12 | use Firebase\JWT\Key; 13 | use UnexpectedValueException; 14 | use Vanilla\JsConnect\Exceptions\FieldNotFoundException; 15 | use Vanilla\JsConnect\Exceptions\InvalidValueException; 16 | 17 | /** 18 | * Handles the jsConnect protocol v3.x. 19 | */ 20 | class JsConnect 21 | { 22 | const VERSION = "php:3"; 23 | 24 | const FIELD_UNIQUE_ID = "id"; 25 | const FIELD_PHOTO = "photo"; 26 | const FIELD_NAME = "name"; 27 | const FIELD_EMAIL = "email"; 28 | const FIELD_ROLES = "roles"; 29 | const FIELD_JWT = "jwt"; 30 | 31 | const TIMEOUT = 10 * 60; 32 | 33 | const ALLOWED_ALGORITHMS = [ 34 | "ES256", 35 | "HS256", 36 | "HS384", 37 | "HS512", 38 | "RS256", 39 | "RS384", 40 | "RS512", 41 | ]; 42 | 43 | const FIELD_STATE = "st"; 44 | const FIELD_USER = "u"; 45 | 46 | const FIELD_REDIRECT_URL = "rurl"; 47 | const FIELD_CLIENT_ID = "kid"; 48 | const FIELD_TARGET = "t"; 49 | 50 | /** 51 | * @var \ArrayAccess 52 | */ 53 | protected $keys; 54 | 55 | /** 56 | * @var string string 57 | */ 58 | protected $signingClientID = ""; 59 | 60 | /** 61 | * @var array 62 | */ 63 | protected $user = []; 64 | 65 | /** 66 | * @var bool 67 | */ 68 | protected $guest = false; 69 | 70 | /** 71 | * @var string 72 | */ 73 | protected $signingAlgorithm; 74 | 75 | /** 76 | * @var int 77 | */ 78 | protected $timeout = self::TIMEOUT; 79 | 80 | /** 81 | * JsConnect constructor. 82 | */ 83 | public function __construct() 84 | { 85 | $this->keys = new \ArrayObject(); 86 | $this->setSigningAlgorithm("HS256"); 87 | } 88 | 89 | /** 90 | * Validate a value that cannot be empty. 91 | * 92 | * @param mixed $value The value to test. 93 | * @param string $valueName The name of the value for the exception message. 94 | * @throws InvalidValueException Throws an exception when the value is empty. 95 | */ 96 | protected static function validateNotEmpty($value, string $valueName): void 97 | { 98 | if ($value === null) { 99 | throw new InvalidValueException("$valueName is required."); 100 | } 101 | if (empty($value)) { 102 | throw new InvalidValueException("$valueName cannot be empty."); 103 | } 104 | } 105 | 106 | /** 107 | * Set the current user's email address. 108 | * 109 | * @param string $email 110 | * @return $this 111 | */ 112 | public function setEmail(string $email) 113 | { 114 | return $this->setUserField(self::FIELD_EMAIL, $email); 115 | } 116 | 117 | /** 118 | * Set a field on the current user. 119 | * 120 | * @param string $key The key on the user. 121 | * @param string|int|bool|array|null $value The value to set. This must be a basic type that can be JSON encoded. 122 | * @return $this 123 | */ 124 | public function setUserField(string $key, $value) 125 | { 126 | $this->user[$key] = $value; 127 | return $this; 128 | } 129 | 130 | /** 131 | * Set the current user's username. 132 | * 133 | * @param string $name 134 | * @return $this 135 | */ 136 | public function setName(string $name) 137 | { 138 | return $this->setUserField(self::FIELD_NAME, $name); 139 | } 140 | 141 | /** 142 | * Set the current user's avatar. 143 | * 144 | * @param string $photo 145 | * @return $this 146 | */ 147 | public function setPhotoURL(string $photo) 148 | { 149 | return $this->setUserField(self::FIELD_PHOTO, $photo); 150 | } 151 | 152 | /** 153 | * Set the current user's unique ID. 154 | * 155 | * @param string $id 156 | * @return $this 157 | */ 158 | public function setUniqueID(string $id) 159 | { 160 | return $this->setUserField(self::FIELD_UNIQUE_ID, $id); 161 | } 162 | 163 | /** 164 | * Handle the authentication request and redirect back to Vanilla. 165 | * 166 | * @param array $query 167 | */ 168 | public function handleRequest(array $query): void 169 | { 170 | try { 171 | $jwt = static::validateFieldExists( 172 | self::FIELD_JWT, 173 | $query, 174 | "querystring" 175 | ); 176 | $location = $this->generateResponseLocation($jwt); 177 | $this->redirect($location); 178 | } catch (Exception $ex) { 179 | echo htmlspecialchars($ex->getMessage()); 180 | } 181 | } 182 | 183 | /** 184 | * Validate that a field exists in a collection. 185 | * 186 | * @param string $field The name of the field to validate. 187 | * @param mixed $collection The collection to look at. 188 | * @param string $collectionName The name of the collection. 189 | * @param bool $validateEmpty If true, make sure the value is also not empty. 190 | * @return mixed Returns the field value if there are no errors. 191 | * @throws FieldNotFoundException Throws an exception when the field is not in the array. 192 | * @throws InvalidValueException Throws an exception when the collection isn't an array or the value is empty. 193 | */ 194 | protected static function validateFieldExists( 195 | string $field, 196 | $collection, 197 | string $collectionName = "payload", 198 | bool $validateEmpty = true 199 | ) { 200 | if (!(is_array($collection) || $collection instanceof \ArrayAccess)) { 201 | throw new InvalidValueException("Invalid array: $collectionName"); 202 | } 203 | 204 | if (!isset($collection[$field])) { 205 | throw new FieldNotFoundException($field, $collectionName); 206 | } 207 | 208 | if ($validateEmpty && empty($collection[$field])) { 209 | throw new InvalidValueException( 210 | "Field cannot be empty: {$collectionName}[{$field}]" 211 | ); 212 | } 213 | 214 | return $collection[$field]; 215 | } 216 | 217 | /** 218 | * Generate the location for an SSO redirect. 219 | * 220 | * @param string $requestJWT 221 | * @return string 222 | */ 223 | public function generateResponseLocation(string $requestJWT): string 224 | { 225 | // Validate the request token. 226 | $request = $this->jwtDecode($requestJWT); 227 | 228 | if ($this->isGuest()) { 229 | $data = [ 230 | self::FIELD_USER => new \stdClass(), 231 | self::FIELD_STATE => $request[self::FIELD_STATE] ?? [], 232 | ]; 233 | } else { 234 | // Generate the response token. 235 | $data = [ 236 | self::FIELD_USER => $this->user, 237 | self::FIELD_STATE => $request[self::FIELD_STATE] ?? [], 238 | ]; 239 | } 240 | $response = $this->jwtEncode($data); 241 | 242 | $location = 243 | $request[self::FIELD_REDIRECT_URL] . 244 | "#" . 245 | http_build_query(["jwt" => $response]); 246 | return $location; 247 | } 248 | 249 | /** 250 | * Decode a JWT with the connection's settings. 251 | * 252 | * @param string $jwt 253 | * @return array 254 | */ 255 | public function jwtDecode(string $jwt): array 256 | { 257 | /** 258 | * @psalm-suppress InvalidArgument 259 | */ 260 | $keys = []; 261 | foreach ($this->keys as $id => $key) { 262 | $keys[$id] = new Key($key, $this->getSigningAlgorithm()); 263 | } 264 | $payload = JWT::decode($jwt, $keys); 265 | $payload = $this->stdClassToArray($payload); 266 | return $payload; 267 | } 268 | 269 | /** 270 | * Convert an object to an array, recursively. 271 | * 272 | * @param array|object $o 273 | * @return array 274 | */ 275 | protected function stdClassToArray($o): array 276 | { 277 | if (!is_array($o) && !($o instanceof \stdClass)) { 278 | throw new UnexpectedValueException( 279 | "JsConnect::stdClassToArray() expects an object or array, scalar given.", 280 | 400 281 | ); 282 | } 283 | 284 | $o = (array) $o; 285 | $r = []; 286 | foreach ($o as $key => $value) { 287 | if (is_array($value) || is_object($value)) { 288 | $r[$key] = $this->stdClassToArray($value); 289 | } else { 290 | $r[$key] = $value; 291 | } 292 | } 293 | return $r; 294 | } 295 | 296 | /** 297 | * Whether or not the user is signed in. 298 | * 299 | * @return bool 300 | */ 301 | public function isGuest(): bool 302 | { 303 | return $this->guest; 304 | } 305 | 306 | /** 307 | * Set whether or not the user is signed in. 308 | * 309 | * @param bool $isGuest 310 | * @return $this 311 | */ 312 | public function setGuest(bool $isGuest) 313 | { 314 | $this->guest = $isGuest; 315 | return $this; 316 | } 317 | 318 | /** 319 | * Wrap a payload in a JWT. 320 | * 321 | * @param array $payload 322 | * @return string 323 | */ 324 | public function jwtEncode(array $payload): string 325 | { 326 | $payload += [ 327 | "v" => $this->getVersion(), 328 | "iat" => $this->getTimestamp(), 329 | "exp" => $this->getTimestamp() + $this->getTimeout(), 330 | ]; 331 | 332 | $jwt = JWT::encode( 333 | $payload, 334 | $this->getSigningSecret(), 335 | $this->getSigningAlgorithm(), 336 | null, 337 | [ 338 | self::FIELD_CLIENT_ID => $this->getSigningClientID(), 339 | ] 340 | ); 341 | return $jwt; 342 | } 343 | 344 | /** 345 | * Get the current timestamp. 346 | * 347 | * This time is used for signing and verifying tokens. 348 | * 349 | * @return int 350 | */ 351 | protected function getTimestamp(): int 352 | { 353 | $r = JWT::$timestamp ?: time(); 354 | return $r; 355 | } 356 | 357 | /** 358 | * Get the secret that is used to sign JWTs. 359 | * 360 | * @return string 361 | */ 362 | public function getSigningSecret(): string 363 | { 364 | return $this->keys[$this->signingClientID]; 365 | } 366 | 367 | /** 368 | * Get the algorithm used to sign tokens. 369 | * 370 | * @return string 371 | */ 372 | public function getSigningAlgorithm(): string 373 | { 374 | return $this->signingAlgorithm; 375 | } 376 | 377 | /** 378 | * Set the algorithm used to sign tokens. 379 | * 380 | * @param string $signingAlgorithm 381 | * @return $this 382 | */ 383 | public function setSigningAlgorithm(string $signingAlgorithm) 384 | { 385 | if (!in_array($signingAlgorithm, static::ALLOWED_ALGORITHMS)) { 386 | throw new UnexpectedValueException("Algorithm not allowed"); 387 | } 388 | $this->signingAlgorithm = $signingAlgorithm; 389 | return $this; 390 | } 391 | 392 | /** 393 | * Get the client ID that is used to sign JWTs. 394 | * 395 | * @return string 396 | */ 397 | public function getSigningClientID(): string 398 | { 399 | return $this->signingClientID; 400 | } 401 | 402 | /** 403 | * Redirect to a new location. 404 | * 405 | * @param string $location 406 | */ 407 | protected function redirect(string $location): void 408 | { 409 | header("Location: $location", true, 302); 410 | die(); 411 | } 412 | 413 | /** 414 | * Set the credentials that will be used to sign requests. 415 | * 416 | * @param string $clientID 417 | * @param string $secret 418 | * @return $this 419 | */ 420 | public function setSigningCredentials(string $clientID, string $secret) 421 | { 422 | $this->keys[$clientID] = $secret; 423 | $this->signingClientID = $clientID; 424 | return $this; 425 | } 426 | 427 | public function getUser(): array 428 | { 429 | return $this->user; 430 | } 431 | 432 | /** 433 | * Set the roles on the user. 434 | * 435 | * @param array $roles 436 | * @return $this 437 | */ 438 | public function setRoles(array $roles) 439 | { 440 | $this->setUserField(self::FIELD_ROLES, $roles); 441 | return $this; 442 | } 443 | 444 | /** 445 | * Returns a JWT header. 446 | * 447 | * @param string $jwt 448 | * 449 | * @return array|null 450 | */ 451 | final public static function decodeJWTHeader(string $jwt): ?array 452 | { 453 | $tks = explode(".", $jwt); 454 | if (count($tks) != 3) { 455 | throw new UnexpectedValueException("Wrong number of segments"); 456 | } 457 | list($headb64) = $tks; 458 | if ( 459 | null === 460 | ($header = JWT::jsonDecode(JWT::urlsafeB64Decode($headb64))) 461 | ) { 462 | throw new UnexpectedValueException("Invalid header encoding"); 463 | } 464 | return json_decode(json_encode($header), true); 465 | } 466 | 467 | /** 468 | * Get the version used to sign requests. 469 | * 470 | * @return string 471 | */ 472 | public function getVersion(): string 473 | { 474 | return self::VERSION; 475 | } 476 | 477 | /** 478 | * Get the JWT expiry timeout. 479 | * 480 | * @return int 481 | */ 482 | public function getTimeout(): int 483 | { 484 | return $this->timeout; 485 | } 486 | 487 | /** 488 | * Set the JWT expiry timeout. 489 | * 490 | * @param int $timeout 491 | * @return $this 492 | */ 493 | public function setTimeout(int $timeout) 494 | { 495 | $this->timeout = $timeout; 496 | return $this; 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /src/JsConnectJSONP.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2020 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Vanilla\JsConnect; 9 | 10 | /** 11 | * This class contains backwards compatible methods for the v2.x jsConnect protocol. 12 | */ 13 | final class JsConnectJSONP 14 | { 15 | const VERSION = "2"; 16 | const TIMEOUT = 24 * 60; 17 | 18 | const FIELD_MAP = [ 19 | "uniqueid" => JsConnect::FIELD_UNIQUE_ID, 20 | "photourl" => JsConnect::FIELD_PHOTO, 21 | ]; 22 | 23 | /** 24 | * Write the jsConnect string for single sign on. 25 | * 26 | * @param array $user An array containing information about the currently signed on user. If no user is signed in, this should be empty. 27 | * @param array $request An array of the $_GET request. 28 | * @param string $clientID The string client ID that you set up in the jsConnect settings page. 29 | * @param string $secret The string secret that you set up in the jsConnect settings page. 30 | * @param string|bool $secure Whether or not to check for security. This is one of these values. 31 | * - true: Check for security and sign the response with an md5 hash. 32 | * - false: Don't check for security, but sign the response with an md5 hash. 33 | * - string: Check for security and sign the response with the given hash algorithm. See hash_algos() for what your server can support. 34 | * - null: Don't check for security and don't sign the response. 35 | * @since 1.1b Added the ability to provide a hash algorithm to $secure. 36 | */ 37 | public static function writeJsConnect( 38 | $user, 39 | $request, 40 | $clientID, 41 | $secret, 42 | $secure = true 43 | ): void { 44 | if (isset($request["jwt"])) { 45 | self::writeJWT($user, $request, $clientID, $secret); 46 | } else { 47 | self::writeJSONP($user, $request, $clientID, $secret, $secure); 48 | } 49 | } 50 | 51 | /** 52 | * This is a backwards compatible method to help migrate jsConnect to the KWT protocol. 53 | * 54 | * @param array $user 55 | * @param array $query 56 | * @param string $clientID 57 | * @param string $secret 58 | */ 59 | protected static function writeJWT( 60 | array $user, 61 | array $query, 62 | string $clientID, 63 | string $secret 64 | ): void { 65 | $jsc = new JsConnect(); 66 | $jsc->setSigningCredentials($clientID, $secret); 67 | 68 | foreach ($user as $key => $value) { 69 | $lkey = strtolower($key); 70 | if (isset(self::FIELD_MAP[$lkey])) { 71 | $key = self::FIELD_MAP[$lkey]; 72 | } 73 | $jsc->setUserField($key, $value); 74 | } 75 | $jsc->handleRequest($query); 76 | } 77 | 78 | /** 79 | * Write the JSONP (v2) protocol response. 80 | * 81 | * @param array $user 82 | * @param array $request 83 | * @param string $clientID 84 | * @param string $secret 85 | * @param bool|string|null $secure 86 | */ 87 | protected static function writeJSONP( 88 | array $user, 89 | array $request, 90 | string $clientID, 91 | string $secret, 92 | $secure 93 | ): void { 94 | $user = array_change_key_case($user); 95 | 96 | // Error checking. 97 | if ($secure) { 98 | // Check the client. 99 | if (!isset($request["v"])) { 100 | $error = [ 101 | "error" => "invalid_request", 102 | "message" => "Missing the v parameter.", 103 | ]; 104 | } elseif ($request["v"] !== self::VERSION) { 105 | $error = [ 106 | "error" => "invalid_request", 107 | "message" => "Unsupported version {$request["v"]}.", 108 | ]; 109 | } elseif (!isset($request["client_id"])) { 110 | $error = [ 111 | "error" => "invalid_request", 112 | "message" => "Missing the client_id parameter.", 113 | ]; 114 | } elseif ($request["client_id"] != $clientID) { 115 | $error = [ 116 | "error" => "invalid_client", 117 | "message" => "Unknown client {$request["client_id"]}.", 118 | ]; 119 | } elseif ( 120 | !isset($request["timestamp"]) && 121 | !isset($request["sig"]) 122 | ) { 123 | if (count($user) > 0) { 124 | // This isn't really an error, but we are just going to return public information when no signature is sent. 125 | $error = [ 126 | "name" => (string) @$user["name"], 127 | "photourl" => @$user["photourl"], 128 | "signedin" => true, 129 | ]; 130 | } else { 131 | $error = ["name" => "", "photourl" => ""]; 132 | } 133 | } elseif ( 134 | !isset($request["timestamp"]) || 135 | !ctype_digit($request["timestamp"]) 136 | ) { 137 | $error = [ 138 | "error" => "invalid_request", 139 | "message" => 140 | "The timestamp parameter is missing or invalid.", 141 | ]; 142 | } elseif (!isset($request["sig"])) { 143 | $error = [ 144 | "error" => "invalid_request", 145 | "message" => "Missing the sig parameter.", 146 | ]; 147 | } elseif ( 148 | abs($request["timestamp"] - self::timestamp()) > self::TIMEOUT 149 | ) { 150 | // Make sure the timestamp hasn't timeout 151 | $error = [ 152 | "error" => "invalid_request", 153 | "message" => "The timestamp is invalid.", 154 | ]; 155 | } elseif (!isset($request["nonce"])) { 156 | $error = [ 157 | "error" => "invalid_request", 158 | "message" => "Missing the nonce parameter.", 159 | ]; 160 | } elseif (!isset($request["ip"])) { 161 | $error = [ 162 | "error" => "invalid_request", 163 | "message" => "Missing the ip parameter.", 164 | ]; 165 | } else { 166 | $signature = self::hash( 167 | $request["ip"] . 168 | $request["nonce"] . 169 | $request["timestamp"] . 170 | $secret, 171 | $secure 172 | ); 173 | if ($signature != $request["sig"]) { 174 | $error = [ 175 | "error" => "access_denied", 176 | "message" => "Signature invalid.", 177 | ]; 178 | } 179 | } 180 | } 181 | 182 | if (isset($error)) { 183 | $result = $error; 184 | } elseif (count($user) > 0) { 185 | if ($secure === null) { 186 | $result = $user; 187 | } else { 188 | $user["ip"] = $request["ip"]; 189 | $user["nonce"] = $request["nonce"]; 190 | $result = self::signJsConnect( 191 | $user, 192 | $clientID, 193 | $secret, 194 | $secure, 195 | true 196 | ); 197 | /** 198 | * @psalm-suppress PossiblyInvalidArrayOffset 199 | */ 200 | $result["v"] = self::VERSION; 201 | } 202 | } else { 203 | $result = ["name" => "", "photourl" => ""]; 204 | } 205 | 206 | $content = json_encode($result); 207 | 208 | if (isset($request["callback"])) { 209 | $content = "{$request["callback"]}($content)"; 210 | } 211 | 212 | if (!headers_sent()) { 213 | $contentType = self::contentType($request); 214 | header($contentType, true); 215 | } 216 | echo $content; 217 | } 218 | 219 | /** 220 | * Get the current timestamp. 221 | * 222 | * @return int 223 | */ 224 | public static function timestamp() 225 | { 226 | return time(); 227 | } 228 | 229 | /** 230 | * Return the hash of a string. 231 | * 232 | * @param string $string The string to hash. 233 | * @param string|bool $secure The hash algorithm to use. true means md5. 234 | * @return string 235 | */ 236 | public static function hash($string, $secure = true) 237 | { 238 | if ($secure === true) { 239 | $secure = "md5"; 240 | } 241 | 242 | switch ($secure) { 243 | case "sha1": 244 | return sha1($string); 245 | break; 246 | case "md5": 247 | case false: 248 | return md5($string); 249 | default: 250 | return hash($secure, $string); 251 | } 252 | } 253 | 254 | /** 255 | * Sign a jsConnect array. 256 | * 257 | * @param array $data 258 | * @param string $clientID 259 | * @param string $secret 260 | * @param string|bool $hashType 261 | * @param bool $returnData 262 | * 263 | * @return array|string 264 | */ 265 | public static function signJsConnect( 266 | array $data, 267 | string $clientID, 268 | string $secret, 269 | $hashType, 270 | bool $returnData = false 271 | ) { 272 | $normalizedData = array_change_key_case($data); 273 | ksort($normalizedData); 274 | 275 | foreach ($normalizedData as $key => $value) { 276 | if ($value === null) { 277 | $normalizedData[$key] = ""; 278 | } 279 | } 280 | 281 | // RFC1738 state that spaces are encoded as '+'. 282 | $stringifiedData = http_build_query( 283 | $normalizedData, 284 | "", 285 | "&", 286 | PHP_QUERY_RFC1738 287 | ); 288 | $signature = self::hash($stringifiedData . $secret, $hashType); 289 | if ($returnData) { 290 | $normalizedData["client_id"] = $clientID; 291 | $normalizedData["sig"] = $signature; 292 | return $normalizedData; 293 | } else { 294 | return $signature; 295 | } 296 | } 297 | 298 | /** 299 | * Based on a jsConnect request, determine the proper response content type. 300 | * 301 | * @param array $request 302 | * @return string 303 | */ 304 | public static function contentType(array $request): string 305 | { 306 | $isJsonp = isset($request["callback"]); 307 | $contentType = $isJsonp 308 | ? "Content-Type: application/javascript; charset=utf-8" 309 | : "Content-Type: application/json; charset=utf-8"; 310 | return $contentType; 311 | } 312 | 313 | /** 314 | * Generate an SSO string suitable for passing in the url for embedded SSO. 315 | * 316 | * @param array $user The user to sso. 317 | * @param string $clientID Your client ID. 318 | * @param string $secret Your secret. 319 | * @return string 320 | */ 321 | public static function ssoString($user, $clientID, $secret) 322 | { 323 | if (!isset($user["client_id"])) { 324 | $user["client_id"] = $clientID; 325 | } 326 | 327 | $string = base64_encode(json_encode($user)); 328 | $timestamp = time(); 329 | $hash = hash_hmac("sha1", "$string $timestamp", $secret); 330 | 331 | $result = "$string $hash $timestamp hmacsha1"; 332 | return $result; 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/JsConnectServer.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2020 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Vanilla\JsConnect; 9 | 10 | use Firebase\JWT\ExpiredException; 11 | use Firebase\JWT\JWT; 12 | use Vanilla\JsConnect\Exceptions\InvalidValueException; 13 | 14 | class JsConnectServer extends JsConnect 15 | { 16 | const FIELD_NONCE = "n"; 17 | const FIELD_COOKIE = "cookie"; 18 | 19 | /** 20 | * @var string 21 | */ 22 | protected $authenticateUrl = ""; 23 | 24 | /** 25 | * @var string 26 | */ 27 | protected $redirectUrl = ""; 28 | 29 | /** 30 | * Set the keystore that is used for validating and signing JWTs. 31 | * 32 | * @param \ArrayAccess $keystore 33 | * @return $this 34 | */ 35 | public function setKeyStore(\ArrayAccess $keystore) 36 | { 37 | $this->keys = $keystore; 38 | return $this; 39 | } 40 | 41 | /** 42 | * Generates a jsConnect request. 43 | * 44 | * @param array $state Additional state information. 45 | * @return array Returns an array in the format `[$requestUrl, $cookie]`. 46 | */ 47 | public function generateRequest(array $state = []): array 48 | { 49 | if (isset($state[self::FIELD_COOKIE])) { 50 | // The cookie was already set. Make sure it's one of ours. 51 | try { 52 | $decodedCookie = $this->jwtDecode($state[self::FIELD_COOKIE]); 53 | } catch (ExpiredException $ex) { 54 | // The cookie was valid, but expired so issue a new one. 55 | goto GENERATE_COOKIE; 56 | } catch (\Exception $ex) { 57 | throw new InvalidValueException( 58 | "Could not use supplied jsConnect SSO token: " . 59 | $ex->getMessage() 60 | ); 61 | } 62 | $nonce = self::validateFieldExists( 63 | self::FIELD_NONCE, 64 | $decodedCookie, 65 | "ssoToken" 66 | ); 67 | $cookie = $state[self::FIELD_COOKIE]; 68 | unset($state[self::FIELD_COOKIE]); 69 | } else { 70 | GENERATE_COOKIE: 71 | $nonce = JWT::urlsafeB64Encode(openssl_random_pseudo_bytes(15)); 72 | $cookie = $this->jwtEncode([self::FIELD_NONCE => $nonce]); 73 | } 74 | 75 | $requestJWT = $this->jwtEncode([ 76 | "st" => 77 | [ 78 | self::FIELD_NONCE => $nonce, 79 | ] + $state, 80 | "rurl" => $this->getRedirectUrl(), 81 | ]); 82 | $requestUrl = 83 | $this->getAuthenticateUrlWithSeparator() . 84 | http_build_query(["jwt" => $requestJWT]); 85 | 86 | return [$requestUrl, $cookie]; 87 | } 88 | 89 | /** 90 | * Validate an SSO response. 91 | * 92 | * @param ?string $jwt The JWT to validate. 93 | * @param ?string $cookieJWT The cookie that was set using `JsConnectServer::generateRequest()`. 94 | * @return array Returns an array in the form: `[$user, $state, $fullPayload]`. 95 | * @throws Exceptions\FieldNotFoundException 96 | * @throws InvalidValueException 97 | */ 98 | public function validateResponse(?string $jwt, ?string $cookieJWT): array 99 | { 100 | static::validateNotEmpty($jwt, "SSO token"); 101 | static::validateNotEmpty($cookieJWT, "State cookie"); 102 | 103 | $payload = $this->jwtDecode($jwt); 104 | $cookie = $this->jwtDecode($cookieJWT); 105 | 106 | $user = 107 | static::validateFieldExists( 108 | static::FIELD_USER, 109 | $payload, 110 | "payload", 111 | false 112 | ) ?: 113 | []; 114 | $state = static::validateFieldExists(static::FIELD_STATE, $payload); 115 | $cookieNonce = static::validateFieldExists( 116 | static::FIELD_NONCE, 117 | $cookie, 118 | "cookie" 119 | ); 120 | $stateNonce = static::validateFieldExists( 121 | static::FIELD_NONCE, 122 | $state, 123 | "state" 124 | ); 125 | 126 | if (!hash_equals($cookieNonce, $stateNonce)) { 127 | throw new InvalidValueException("The response nonce is invalid."); 128 | } 129 | 130 | return [$user, $state, $payload]; 131 | } 132 | 133 | /** 134 | * Add a new key/secret pair that can be used to verify signatures. 135 | * 136 | * @param string $clientID 137 | * @param string $secret 138 | * @return $this 139 | */ 140 | public function addKey(string $clientID, string $secret) 141 | { 142 | $this->keys[$clientID] = $secret; 143 | return $this; 144 | } 145 | 146 | /** 147 | * The URL on the client's site that will run the jsConnect client library. 148 | * 149 | * This URL is analogous to OAuth's authenticate URL. 150 | * 151 | * @return string 152 | */ 153 | public function getAuthenticateUrl(): string 154 | { 155 | return $this->authenticateUrl; 156 | } 157 | 158 | /** 159 | * Get the authenticate URL with the proper query string separator. 160 | * 161 | * @return string 162 | */ 163 | protected function getAuthenticateUrlWithSeparator(): string 164 | { 165 | return $this->authenticateUrl . 166 | (strpos($this->authenticateUrl, "?") === false ? "?" : "&"); 167 | } 168 | 169 | /** 170 | * Set the URL on the client's site that will run the jsConnect client library. 171 | * 172 | * @param string $authenticateUrl 173 | * @return $this 174 | */ 175 | public function setAuthenticateUrl(string $authenticateUrl) 176 | { 177 | $this->authenticateUrl = $authenticateUrl; 178 | return $this; 179 | } 180 | 181 | /** 182 | * The URL on Vanilla' that will process the client's authentication response. 183 | * 184 | * @return string 185 | */ 186 | public function getRedirectUrl(): string 187 | { 188 | return $this->redirectUrl; 189 | } 190 | 191 | /** 192 | * @param string $redirectUrl 193 | * @return $this 194 | */ 195 | public function setRedirectUrl(string $redirectUrl) 196 | { 197 | $this->redirectUrl = $redirectUrl; 198 | return $this; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/functions.compat.php: -------------------------------------------------------------------------------- 1 | 6 | * @version 2.0 7 | * @copyright 2008-2020 Vanilla Forums, Inc. 8 | * @license MIT 9 | */ 10 | 11 | namespace { 12 | use Vanilla\JsConnect\JsConnectJSONP; 13 | 14 | function writeJsConnect($user, $request, $clientID, $secret, $secure = true) 15 | { 16 | JsConnectJSONP::writeJsConnect( 17 | $user, 18 | $request, 19 | $clientID, 20 | $secret, 21 | $secure 22 | ); 23 | } 24 | 25 | function signJsConnect( 26 | $data, 27 | $clientID, 28 | $secret, 29 | $hashType, 30 | $returnData = false 31 | ) { 32 | return JsConnectJSONP::signJsConnect( 33 | $data, 34 | $clientID, 35 | $secret, 36 | $hashType, 37 | $returnData 38 | ); 39 | } 40 | 41 | function jsHash($string, $secure = true) 42 | { 43 | return JsConnectJSONP::hash($string, $secure); 44 | } 45 | 46 | function jsTimestamp() 47 | { 48 | return JsConnectJSONP::timestamp(); 49 | } 50 | 51 | function jsSSOString($user, $clientID, $secret) 52 | { 53 | return JsConnectJSONP::ssoString($user, $clientID, $secret); 54 | } 55 | 56 | function jsConnectContentType(array $request): string 57 | { 58 | return JsConnectJSONP::contentType($request); 59 | } 60 | } 61 | --------------------------------------------------------------------------------