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 Branca\Branca;
25: use SimpleID\Crypt\Random;
26: use SimpleID\Store\StoreManager;
27: use SimpleID\Util\SecureString;
28:
29: /**
30: * An OAuth access or refresh token.
31: *
32: * Tokens generated by this class are *hybrid* tokens. That is, the encoded token string
33: * (which is returned to the client) contains encrypted basic data on the authorisation
34: * and scope associated with the token. Additional data may also be stored on the server side.
35: * Therefore a resource server, with the appropriate keys, can decrypt the encoded token without
36: * making further calls to the SimpleID server.
37: *
38: * This class cannot be instantiated directly. It can only be created by its subclasses.
39: */
40: abstract class Token {
41: /** The separator between the token ID and the source reference */
42: const GRANT_REF_SEPARATOR = '~';
43:
44: /** Denotes a token without an expiry time */
45: const TTL_PERPETUAL = 0;
46:
47: const KEY_FQAID = 'a';
48: const KEY_TYPE = 't';
49: const KEY_CACHE_HASH = 'h';
50: const KEY_ID = 'i';
51: const KEY_GRANTREF = 'r';
52: const KEY_SCOPEREF = 's';
53: const KEY_EXPIRE = 'x';
54:
55: /** @var Branca the branca token generator */
56: protected $branca;
57:
58: /** @var string the unique ID of this token */
59: protected $id;
60:
61: /** @var Authorization the authorisation */
62: protected $authorization;
63:
64: /** @var array<string> */
65: protected $scope;
66:
67: /** @var int|null the expiry time */
68: protected $expire = NULL;
69:
70: /** @var string|null the grant reference (a reference to the authorization code or refresh token) */
71: protected $grant_ref = NULL;
72:
73: /** @var array<string, mixed> additional data to be stored on the server in relation to the token */
74: protected $additional = [];
75:
76: /** @var string|null the encoded token */
77: protected $encoded = NULL;
78:
79: /** @var bool whether the token has been parsed properly */
80: protected $is_parsed = false;
81:
82: /** Creates a token */
83: protected function __construct() {
84: $this->branca = new Branca((StoreManager::instance())->getKey('oauth-token', true));
85: }
86:
87: /**
88: * Initialises a token.
89: *
90: * @param Authorization $authorization the underlying authorisation
91: * @param array<string>|string $scope the scope of the token
92: * @param int $expires_in the validity of the token, in seconds, or
93: * {@link TTL_PERPETUAL}
94: * @param TokenGrantType $grant the token grant
95: * @param array<string, mixed> $additional additional data to be stored on the
96: * server
97: * @return void
98: */
99: protected function init($authorization, $scope = [], $expires_in = self::TTL_PERPETUAL, $grant = NULL, $additional = []) {
100: $rand = new Random();
101:
102: $this->id = $rand->id();
103: $this->authorization = $authorization;
104: if (is_string($scope)) $scope = explode(' ', $scope);
105: if (count($scope) == 0) {
106: $this->scope = $authorization->getScope();
107: } else {
108: $this->scope = $authorization->filterScope($scope);
109: }
110:
111: if ($grant != null) $this->grant_ref = $grant->getGrantRef();
112: if ($expires_in > 0) $this->expire = time() + $expires_in;
113: $this->additional = $additional;
114: }
115:
116: /**
117: * Returns whether the token is valid.
118: *
119: * A token is valid if it is successfully created or parsed, and
120: * is not expired (if the token has an expiry date).
121: *
122: * Note that a valid token be still not provide sufficient authority
123: * to access protected resources. You will also need to check
124: * the token's scope using the {@link hasScope()} method.
125: *
126: * @return bool true if the token is valid
127: */
128: public function isValid() {
129: if (!$this->is_parsed) return false;
130: if ($this->expire != null) return !$this->hasExpired();
131: return true;
132: }
133:
134: /**
135: * Returns the unique ID for this token.
136: *
137: * @return string the ID
138: */
139: public function getID() {
140: return $this->id;
141: }
142:
143: /**
144: * Returns the type of the token (e.g. `access_token`, `refresh_token`).
145: * Subclasses must implement this method
146: *
147: * @return string the token type
148: */
149: abstract public function getType(): string;
150:
151: /**
152: * Returns the authorisation that created this token.
153: *
154: * @return Authorization the authorisation object
155: */
156: public function getAuthorization() {
157: return $this->authorization;
158: }
159:
160: /**
161: * Returns the scope covered by the token
162: *
163: * @return array<string> the scope
164: */
165: public function getScope() {
166: return $this->scope;
167: }
168:
169: /**
170: * Checks whether the token covers a specified scope.
171: *
172: * This method will return true if the token covers *all* of the
173: * scope specified by `$scope`.
174: *
175: * @param string|array<string> $scope the scope to test
176: * @return bool true if the token covers all of the specified
177: * scope
178: */
179: public function hasScope($scope) {
180: if (!is_array($scope)) $scope = explode(' ', $scope);
181: return (count(array_diff($scope, $this->scope)) == 0);
182: }
183:
184: /**
185: * Returns additional data stored on the server for this token
186: *
187: * @return array<string, mixed> the additional data
188: */
189: public function getAdditionalData() {
190: return $this->additional;
191: }
192:
193: /**
194: * Checks whether the token has expired. If the token has no expiry date,
195: * this function will always return `false`.
196: *
197: * @return bool true if the token has expired
198: */
199: public function hasExpired() {
200: if ($this->expire == null) return false;
201: return (time() >= $this->expire);
202: }
203:
204: /**
205: * Returns the expiry time for this token, if any.
206: *
207: * @return int|null the expiry time, or null if the token does not
208: * expire
209: */
210: public function getExpiry() {
211: return $this->expire;
212: }
213:
214: /**
215: * Returns the encoded token as a string.
216: *
217: * @return string the encoded token
218: */
219: public function getEncoded() {
220: return $this->encoded;
221: }
222:
223: /**
224: * Revokes a token
225: *
226: * @return void
227: */
228: public function revoke() {
229: $cache = \Cache::instance();
230: $cache->clear($this->getCacheKey());
231: }
232:
233: /**
234: * Revokes all tokens issued from a specifed authorisation and,
235: * optionally, a grant.
236: *
237: * @param Authorization $authorization the authorisation for which
238: * tokens are to be revoked
239: * @param TokenGrantType|string $grant if specified, only delete tokens issued
240: * from this grant
241: * @return void
242: */
243: public static function revokeAll($authorization, $grant = null) {
244: $cache = \Cache::instance();
245:
246: if ($grant != null) {
247: if ($grant instanceof TokenGrantType) {
248: $grant_ref = $grant->getGrantRef();
249: } elseif (is_string($grant)) {
250: $grant_ref = $grant;
251: } else {
252: // This shouldn't happen
253: throw new \InvalidArgumentException('$grant must be TokenGrantType or string');
254: }
255: $suffix = self::GRANT_REF_SEPARATOR . $grant_ref;
256: } else {
257: $suffix = '';
258: }
259:
260: $suffix .= '.' . $authorization->getFullyQualifiedID() . '.oauth_token';
261: $cache->reset($suffix);
262: }
263:
264: /**
265: * Returns the key used to store data for this token in the FatFree cache
266: *
267: * @return string the key
268: */
269: protected function getCacheKey() {
270: $key = $this->id;
271: if ($this->grant_ref != NULL) {
272: $key .= self::GRANT_REF_SEPARATOR . $this->grant_ref;
273: }
274: $key .= '.' . $this->authorization->getFullyQualifiedID() . '.oauth_token';
275: return $key;
276: }
277:
278: /**
279: * Parses an encoded token
280: *
281: * @return void
282: */
283: protected function parse() {
284: $store = StoreManager::instance();
285: $cache = \Cache::instance();
286:
287: try {
288: $message = $this->branca->decode($this->encoded);
289: $token_data = json_decode($message, true);
290:
291: $this->id = $token_data[self::KEY_ID];
292: if ($token_data[self::KEY_TYPE] != $this->getType()) return;
293:
294: list($auth_state, $aid) = explode('.', $token_data[self::KEY_FQAID]);
295: $this->scope = $this->resolveScope($token_data[self::KEY_SCOPEREF]);
296: if (isset($token_data[self::KEY_EXPIRE])) $this->expire = $token_data[self::KEY_EXPIRE];
297: if (isset($token_data[self::KEY_GRANTREF])) $this->grant_ref = $token_data[self::KEY_GRANTREF];
298:
299: /** @var Authorization $authorization */
300: $authorization = $store->loadAuth($aid);
301: $this->authorization = $authorization;
302: if ($this->authorization == NULL) return;
303: if ($this->authorization->getAuthState() != $auth_state) return;
304:
305: $server_data = $cache->get($this->getCacheKey());
306: if ($server_data === false) return;
307: if (base64_encode(hash('sha256', serialize($server_data), true)) !== $token_data[self::KEY_CACHE_HASH]) return;
308: $this->additional = $server_data['additional'];
309:
310: $this->is_parsed = true;
311: } catch (\RuntimeException $e) {
312: return;
313: }
314: }
315:
316: /**
317: * Encodes a token.
318: *
319: * @param array<string, mixed> $server_data data to be stored on the server side
320: * @param array<string, mixed> $token_data data to be encoded in the token
321: * @return void
322: */
323: protected function encode($server_data = [], $token_data = []) {
324: $cache = \Cache::instance();
325:
326: $fqaid = $this->authorization->getFullyQualifiedID();
327:
328: $server_data = array_merge([
329: 'id' => $this->id,
330: 'type' => $this->getType(),
331: 'fqaid' => $fqaid,
332: 'scope' => $this->scope,
333: 'additional' => $this->additional
334: ], $server_data);
335: $token_data = array_merge([
336: self::KEY_ID => $server_data['id'],
337: self::KEY_TYPE => $this->getType(),
338: self::KEY_FQAID => $server_data['fqaid'],
339: self::KEY_SCOPEREF => $this->getScopeRef($this->scope),
340: ], $token_data);
341:
342: if ($this->expire != NULL) {
343: $server_data['expire'] = $this->expire;
344: $token_data[self::KEY_EXPIRE] = $this->expire;
345: }
346:
347: if ($this->grant_ref != NULL) {
348: $server_data['grant_ref'] = $this->grant_ref;
349: $token_data[self::KEY_GRANTREF] = $this->grant_ref;
350: }
351:
352: $cache->set($this->getCacheKey(), $server_data, ($this->expire != NULL) ? $this->expire - time() : 0);
353: $token_data[self::KEY_CACHE_HASH] = base64_encode(hash('sha256', serialize($server_data), true));
354:
355: $json = json_encode($token_data);
356: assert($json != false);
357: $this->encoded = $this->branca->encode($json);
358: }
359:
360: /**
361: * Compresses a scope string.
362: *
363: * Each SimpleID installation compiles a mapping of all the known scopes.
364: * This function compresses a scope string by replacing the individual
365: * scope items with a reference to this map.
366: *
367: * @param array<string> $scope the scope to compress
368: * @return string the compressed scope reference
369: */
370: protected function getScopeRef($scope) {
371: $ref = [];
372:
373: $store = StoreManager::instance();
374: $scope_map = $store->getSetting('oauth_scope', []);
375:
376: foreach ($scope as $item) {
377: $i = array_search($item, $scope_map);
378: if ($i === false) {
379: $scope_map[] = $item;
380: $i = count($scope_map) - 1;
381: }
382: $ref[] = '\\' . $i;
383: }
384:
385: $store->setSetting('oauth_scope', $scope_map);
386: return implode(' ', $ref);
387: }
388:
389: /**
390: * Resolves a compressed scope reference.
391: *
392: * This function is the reverse of {@link getScopeRef()}.
393: *
394: * @param string $ref the compressed scope reference
395: * @return array<string> array of scope items
396: */
397: protected function resolveScope($ref) {
398: $scope = [];
399:
400: $store = StoreManager::instance();
401: $scope_map = $store->getSetting('oauth_scope', []);
402:
403: $refs = explode(' ', $ref);
404: foreach ($refs as $item) {
405: if (preg_match('/\\\\(\d+)/', $item, $matches)) {
406: $scope[] = $scope_map[$matches[1]];
407: }
408: }
409:
410: return $scope;
411: }
412:
413: /**
414: * Returns the current scope map used in the {@link getScopeRef()} and
415: * {@link resolveScope()} functions.
416: *
417: * @return array<string> the scope map
418: */
419: static function getScopeRefMap() {
420: $store = StoreManager::instance();
421: return $store->getSetting('oauth_scope', []);
422: }
423: }
424:
425: ?>