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: ?>