| 1: | <?php |
| 2: | /* |
| 3: | * SimpleID |
| 4: | * |
| 5: | * Copyright (C) Kelvin Mo 2014-2025 |
| 6: | * |
| 7: | * This program is free software; you can redistribute it and/or |
| 8: | * modify it under the terms of the GNU General Public |
| 9: | * License as published by the Free Software Foundation; either |
| 10: | * version 2 of the License, or (at your option) any later version. |
| 11: | * |
| 12: | * This program is distributed in the hope that it will be useful, |
| 13: | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 14: | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| 15: | * General Public License for more details. |
| 16: | * |
| 17: | * You should have received a copy of the GNU General Public |
| 18: | * License along with this program; if not, write to the Free |
| 19: | * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. |
| 20: | */ |
| 21: | |
| 22: | namespace SimpleID\Protocols\OAuth; |
| 23: | |
| 24: | use \Cache; |
| 25: | use SimpleID\Crypt\Random; |
| 26: | use SimpleID\Store\Storable; |
| 27: | use SimpleID\Store\StoreManager; |
| 28: | use SimpleID\Crypt\SecurityToken; |
| 29: | use SimpleID\Crypt\OpaqueIdentifier; |
| 30: | |
| 31: | |
| 32: | /** |
| 33: | * An OAuth authorisation. |
| 34: | * |
| 35: | * An OAuth authorisation permits an OAuth *client* to access resources with |
| 36: | * a specified *scope* owned by the resource *owner*. Authorisation codes, |
| 37: | * access and refresh tokens are issued based on this authorisation. |
| 38: | * |
| 39: | * Within SimpleID, the owner (usually a user, but can sometimes be the |
| 40: | * client object itself) and the client must be {@link Storable}. |
| 41: | * |
| 42: | * Each authorisation in SimpleID contains a randomly generated *authorisation state*. |
| 43: | * The authorisation state is stored permanently along with the authorisation. |
| 44: | * An authorisation state changes when: |
| 45: | * |
| 46: | * - a new authorisation is requested with a scope that is narrower (but not |
| 47: | * wider) than the scope stored with the authorisation |
| 48: | * - the user revokes the authorisation |
| 49: | * - a token grant (e.g. authorisation code or refresh token) is consumed |
| 50: | * - a security incident occurs |
| 51: | * |
| 52: | * Authorisation codes, access and refresh tokens are issued based on a particular |
| 53: | * authorisation state. Therefore, if the authorisation state changes, all of |
| 54: | * these credentials are automatically revoked. |
| 55: | * |
| 56: | * The *authorisation ID* is a hash of the client and owner IDs. The |
| 57: | * *fully qualified authorisation ID* is the authorisation ID along with the current |
| 58: | * authorisation state. |
| 59: | * |
| 60: | */ |
| 61: | class Authorization implements Storable { |
| 62: | |
| 63: | /** Separator for the authorisation ID and the authorisation state */ |
| 64: | const AUTH_STATE_SEPARATOR = '.'; |
| 65: | |
| 66: | /** @var string */ |
| 67: | private $id; |
| 68: | |
| 69: | /** @var string */ |
| 70: | private $auth_state; |
| 71: | |
| 72: | /** @var string the type and ID of the resource owner */ |
| 73: | protected $owner_ref; |
| 74: | /** @var string the type and ID of the client */ |
| 75: | protected $client_ref; |
| 76: | /** @var array<string> the scope of the authorisation */ |
| 77: | protected $available_scope; |
| 78: | |
| 79: | /** @var bool whether refresh tokens are issued */ |
| 80: | protected $issue_refresh_token = true; |
| 81: | |
| 82: | /** @var array<string, mixed> additional data to be stored with the authorization */ |
| 83: | public $additional = []; |
| 84: | |
| 85: | /** |
| 86: | * Creates an authorisation. |
| 87: | * |
| 88: | * Once the authorisation is created, it needs to be stored using the |
| 89: | * {@link \SimpleID\Store\StoreManager::save()} method. |
| 90: | * |
| 91: | * @param Storable $owner the Storable object representing the resource |
| 92: | * owner |
| 93: | * @param Storable $client the Storable object representing the |
| 94: | * client |
| 95: | * @param string|array<string> $scope a space-delimited string representing the scope |
| 96: | * of the authorization |
| 97: | * @param bool $issue_refresh_token whether to issue a refresh token if |
| 98: | * permitted |
| 99: | * @param string|null $auth_state an existing authorisation state, or null to |
| 100: | * reset the authorisation state |
| 101: | */ |
| 102: | public function __construct($owner, $client, $scope = '', $issue_refresh_token = true, $auth_state = NULL) { |
| 103: | $this->owner_ref = $owner->getStoreType() . ':' . $owner->getStoreID(); |
| 104: | $this->client_ref = $client->getStoreType() . ':' . $client->getStoreID(); |
| 105: | $this->available_scope = (!is_array($scope)) ? explode(' ', $scope) : $scope; |
| 106: | |
| 107: | $this->id = self::buildID($owner, $client); |
| 108: | if ($auth_state == NULL) { |
| 109: | $this->resetAuthState(); |
| 110: | } else { |
| 111: | $this->auth_state = $auth_state; |
| 112: | } |
| 113: | |
| 114: | $this->issue_refresh_token = $issue_refresh_token; |
| 115: | } |
| 116: | |
| 117: | /** |
| 118: | * Returns the resource owner |
| 119: | * |
| 120: | * @return Storable the resource owner |
| 121: | */ |
| 122: | public function getOwner() { |
| 123: | $args = func_get_args(); |
| 124: | return $this->getStorable($this->owner_ref, $args); |
| 125: | } |
| 126: | |
| 127: | /** |
| 128: | * Returns the client |
| 129: | * |
| 130: | * @return Storable the client |
| 131: | */ |
| 132: | public function getClient() { |
| 133: | $args = func_get_args(); |
| 134: | return $this->getStorable($this->client_ref, $args); |
| 135: | } |
| 136: | |
| 137: | /** |
| 138: | * Returns a Storable object based on a reference. |
| 139: | * |
| 140: | * A reference of a Storable object is its store type (from |
| 141: | * {@link SimpleID\Store\Storable::getStoreType()}) and its ID (from |
| 142: | * {@link SimpleID\Store\Storable::getStoreID()}), separated by a colon. |
| 143: | * |
| 144: | * @param string $ref the reference to the storable object |
| 145: | * @param array<mixed> $args additional parameters |
| 146: | * @return Storable the storable object or null |
| 147: | */ |
| 148: | protected function getStorable($ref, $args = []) { |
| 149: | $store = StoreManager::instance(); |
| 150: | $f3 = \Base::instance(); |
| 151: | |
| 152: | list($type, $id) = explode(':', $ref, 2); |
| 153: | array_unshift($args, $id); |
| 154: | return call_user_func_array([ $store, 'load' . ucfirst($f3->camelCase($type)) ], $args); |
| 155: | } |
| 156: | |
| 157: | /** |
| 158: | * Returns the scope of the authorisation. |
| 159: | * |
| 160: | * @return array<string> the scope of this authorisation |
| 161: | */ |
| 162: | public function getScope() { |
| 163: | return $this->available_scope; |
| 164: | } |
| 165: | |
| 166: | /** |
| 167: | * Changes the scope of the authorisation. |
| 168: | * |
| 169: | * If the new scope is narrower than the current scope (i.e contains |
| 170: | * fewer elements), then the authorisation state is reset. The new |
| 171: | * authorisation state can be obtained using the {@link getAuthState()} |
| 172: | * method. |
| 173: | * |
| 174: | * @param string|array<string> $scope the new scope as a space-delimited string |
| 175: | * or an array |
| 176: | * @return void |
| 177: | */ |
| 178: | public function setScope($scope) { |
| 179: | if (!is_array($scope)) $scope = explode(' ', $scope); |
| 180: | |
| 181: | if (count(array_diff($this->available_scope, $scope)) > 0) { |
| 182: | // Scope narrowing - reset auth state |
| 183: | $this->resetAuthState(); |
| 184: | // Note that may be scope in $scope that are not yet in $available_scope |
| 185: | $this->available_scope = array_intersect($this->available_scope, $scope); |
| 186: | } |
| 187: | |
| 188: | $added_scope = array_diff($scope, $this->available_scope); |
| 189: | if (count($added_scope) > 0) { |
| 190: | // Scope widening |
| 191: | $this->available_scope = array_merge($this->available_scope, $added_scope); |
| 192: | } |
| 193: | } |
| 194: | |
| 195: | /** |
| 196: | * Checks whether the authorisation covers a specified scope. |
| 197: | * |
| 198: | * This method will return true if the authorisation covers *all* of the |
| 199: | * scope specified by `$scope`. |
| 200: | * |
| 201: | * @param string|array<string> $scope the scope to test |
| 202: | * @return bool true if the authorisation covers all of the specified |
| 203: | * scope |
| 204: | */ |
| 205: | public function hasScope($scope) { |
| 206: | if (!is_array($scope)) $scope = explode(' ', $scope); |
| 207: | return (count(array_diff($scope, $this->available_scope)) == 0); |
| 208: | } |
| 209: | |
| 210: | /** |
| 211: | * Filter a scope parameter so that it is equal to or narrower than |
| 212: | * the scope authorised under this authorization. |
| 213: | * |
| 214: | * @param string|array<string> $scope the scope to filter |
| 215: | * @return array<string> the filtered scope |
| 216: | */ |
| 217: | public function filterScope($scope) { |
| 218: | if (!is_array($scope)) $scope = explode(' ', $scope); |
| 219: | return array_intersect($this->available_scope, $scope); |
| 220: | } |
| 221: | |
| 222: | /** |
| 223: | * Returns the current authorisation state |
| 224: | * |
| 225: | * @return string the authorisation state |
| 226: | */ |
| 227: | public function getAuthState() { |
| 228: | return $this->auth_state; |
| 229: | } |
| 230: | |
| 231: | /** |
| 232: | * Resets the current authorisation state |
| 233: | * |
| 234: | * @return string the new authorisation state |
| 235: | */ |
| 236: | public function resetAuthState() { |
| 237: | $rand = new Random(); |
| 238: | $this->auth_state = substr($rand->secret(7), -9); |
| 239: | return $this->auth_state; |
| 240: | } |
| 241: | |
| 242: | /** |
| 243: | * Returns whether a refresh token will be issued if permitted. |
| 244: | * |
| 245: | * If this is true and the OAuth specification permits a refresh token |
| 246: | * to be issued, a refresh token ill be issued when {@link issueCode()} is |
| 247: | * called. |
| 248: | * |
| 249: | * @return bool true a refresh token will be issued |
| 250: | */ |
| 251: | public function getIssueRefreshToken() { |
| 252: | return $this->issue_refresh_token; |
| 253: | } |
| 254: | |
| 255: | /** |
| 256: | * Creates an OAuth authorisation code. |
| 257: | * |
| 258: | * @param string $redirect_uri the redirect URI associated with the code |
| 259: | * @param string|array<string> $scope the allowed scope - this should be a subset of |
| 260: | * the scope provided by the authorisation, or null if all of the authorisation's |
| 261: | * scope is to be included |
| 262: | * @param array<string, mixed> $additional additional data to be stored in the code |
| 263: | * @return string the authorisation code |
| 264: | */ |
| 265: | public function issueCode($redirect_uri, $scope = null, $additional = []) { |
| 266: | if ($scope == null) $scope = $this->available_scope; |
| 267: | $code = Code::create($this, $redirect_uri, $scope, $additional); |
| 268: | |
| 269: | return $code->getCode(); |
| 270: | } |
| 271: | |
| 272: | /** |
| 273: | * Issues an access token and, if set, a refresh token. |
| 274: | * |
| 275: | * This function calls {@link issueAccessToken()} to issue an access token. |
| 276: | * It will also call {@link issueRefreshToken()} if the authorisation was |
| 277: | * created with $issue_refresh_token set to true. |
| 278: | * |
| 279: | * @param array<string> $scope the scope to be included in the tokens |
| 280: | * @param int $expires_in the time over which the access token will be valid, |
| 281: | * in seconds, or {@link SimpleID\Protocols\OAuth\Token::TTL_PERPETUAL} if the token is not to expire |
| 282: | * @param TokenGrantType $grant the grant, if any, from which the token is to be |
| 283: | * generated |
| 284: | * @param array<string, mixed> $additional additional data to be stored on the server for this |
| 285: | * token |
| 286: | * @return array<string, string> an array of parameters that can be included in the OAuth token |
| 287: | * endpoint response |
| 288: | */ |
| 289: | public function issueTokens($scope = [], $expires_in = Token::TTL_PERPETUAL, $grant = null, $additional = []) { |
| 290: | $results = $this->issueAccessToken($scope, $expires_in, $grant, $additional); |
| 291: | |
| 292: | if ($this->issue_refresh_token) { |
| 293: | $results = array_merge($results, $this->issueRefreshToken($scope, $grant, $additional)); |
| 294: | } |
| 295: | return $results; |
| 296: | } |
| 297: | |
| 298: | /** |
| 299: | * Issues an access token. |
| 300: | * |
| 301: | * @param array<string> $scope the scope to be included in the access token |
| 302: | * @param int $expires_in the time over which the access token will be valid, |
| 303: | * in seconds, or {@link SimpleID\Protocols\OAuth\Token::TTL_PERPETUAL} if the token is not to expire |
| 304: | * @param TokenGrantType $grant the grant, if any, from which the token is to be |
| 305: | * generated |
| 306: | * @param array<string, mixed> $additional additional data to be stored on the server for this |
| 307: | * token |
| 308: | * @return array<string, string> an array of parameters that can be included in the OAuth token |
| 309: | * endpoint response |
| 310: | */ |
| 311: | public function issueAccessToken($scope = [], $expires_in = Token::TTL_PERPETUAL, $grant = null, $additional = []) { |
| 312: | $results = []; |
| 313: | |
| 314: | $token = AccessToken::create($this, $scope, $expires_in, $grant, $additional); |
| 315: | |
| 316: | $results['access_token'] = $token->getEncoded(); |
| 317: | $results['token_type'] = $token->getAccessTokenType(); |
| 318: | if ($expires_in != Token::TTL_PERPETUAL) $results['expires_in'] = strval($expires_in); |
| 319: | |
| 320: | return $results; |
| 321: | } |
| 322: | |
| 323: | /** |
| 324: | * Issues a refresh token. |
| 325: | * |
| 326: | * @param array<string> $scope the scope to be included in the access token |
| 327: | * @param TokenGrantType $grant the grant, if any, from which the token is to be |
| 328: | * generated |
| 329: | * @param array<string, mixed> $additional additional data to be stored on the server for this |
| 330: | * token |
| 331: | * @return array<string, string> an array of parameters that can be included in the OAuth token |
| 332: | * endpoint response |
| 333: | */ |
| 334: | protected function issueRefreshToken($scope = [], $grant = NULL, $additional = []) { |
| 335: | $token = RefreshToken::create($this, $scope, $grant, $additional); |
| 336: | return [ 'refresh_token' => $token->getEncoded() ]; |
| 337: | } |
| 338: | |
| 339: | /** |
| 340: | * Revokes all access and refresh tokens that were generated from |
| 341: | * a particular grant. |
| 342: | * |
| 343: | * @param TokenGrantType $grant the grant |
| 344: | * @return void |
| 345: | */ |
| 346: | public function revokeTokensFromGrant($grant) { |
| 347: | Token::revokeAll($this, $grant); |
| 348: | } |
| 349: | |
| 350: | /** |
| 351: | * Revokes all access and refresh tokens for this authorisation. |
| 352: | * |
| 353: | * @return void |
| 354: | */ |
| 355: | public function revokeAllTokens() { |
| 356: | Token::revokeAll($this); |
| 357: | } |
| 358: | |
| 359: | /** |
| 360: | * Returns the fully-qualified ID for this authorisation. |
| 361: | * |
| 362: | * The fully-qualified ID for an authorisation is the authorisation |
| 363: | * state along with a hash of the owner and the client IDs. |
| 364: | * |
| 365: | * @return string the fully qualified ID |
| 366: | */ |
| 367: | public function getFullyQualifiedID() { |
| 368: | return $this->auth_state . self::AUTH_STATE_SEPARATOR . $this->id; |
| 369: | } |
| 370: | |
| 371: | public function getStoreType() { |
| 372: | return 'oauth'; |
| 373: | } |
| 374: | |
| 375: | public function getStoreID() { |
| 376: | return $this->id; |
| 377: | } |
| 378: | |
| 379: | public function setStoreID($id) { |
| 380: | $this->id = $id; |
| 381: | } |
| 382: | |
| 383: | /** |
| 384: | * Builds a hash of the owner and client for identification |
| 385: | * purposes |
| 386: | * |
| 387: | * @param Storable $owner the Storable object representing the resource |
| 388: | * owner |
| 389: | * @param Storable $client the Storable object representing the |
| 390: | * client |
| 391: | * @return string the hash |
| 392: | */ |
| 393: | static public function buildID($owner, $client) { |
| 394: | $owner_id = $owner->getStoreType() . ':' . $owner->getStoreID(); |
| 395: | $client_id = $client->getStoreType() . ':' . $client->getStoreID(); |
| 396: | |
| 397: | $opaque = new OpaqueIdentifier(); |
| 398: | return $opaque->generate($owner_id . ':' . $client_id); |
| 399: | } |
| 400: | } |
| 401: | |
| 402: | ?> |