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:
23: namespace SimpleID\Protocols\OAuth;
24:
25: use Psr\Log\LogLevel;
26: use SimpleID\Auth\AuthManager;
27: use SimpleID\Module;
28: use SimpleID\ModuleManager;
29: use SimpleID\Base\ScopeInfoCollectionEvent;
30: use SimpleID\Base\ConsentEvent;
31: use SimpleID\Base\RequestState;
32: use SimpleID\Crypt\SecurityToken;
33: use SimpleID\Protocols\ProtocolResult;
34: use SimpleID\Protocols\ProtocolResultEvent;
35: use SimpleID\Store\StoreManager;
36: use SimpleID\Util\Events\GenericStoppableEvent;
37: use SimpleID\Util\Events\BaseDataCollectionEvent;
38: use SimpleID\Util\Forms\FormState;
39: use SimpleID\Util\Forms\FormBuildEvent;
40: use SimpleID\Util\Forms\FormSubmitEvent;
41: use SimpleID\Util\UI\Template;
42:
43: /**
44: * The module for authentication using OAuth.
45: *
46: * This module contains basic functions for process authorisation
47: * requests and granting access tokens.
48: */
49: class OAuthModule extends Module implements ProtocolResult {
50:
51: const DEFAULT_SCOPE = 'tag:simpleid.sf.net,2014:oauth:default';
52:
53: /** @var array<string, mixed>|null */
54: static private $oauth_scope_settings = NULL;
55:
56: /** @var OAuthManager */
57: protected $oauth;
58:
59: /** @var ModuleManager */
60: protected $mgr;
61:
62: static function init($f3) {
63: $f3->route('GET @oauth_auth: /oauth/auth', 'SimpleID\Protocols\OAuth\OAuthModule->auth');
64: $f3->route('POST @oauth_token: /oauth/token', 'SimpleID\Protocols\OAuth\OAuthModule->token');
65: $f3->route('POST @oauth_consent: /oauth/consent', 'SimpleID\Protocols\OAuth\OAuthModule->consent');
66: $f3->route('POST @oauth_revoke: /oauth/revoke', 'SimpleID\Protocols\OAuth\OAuthModule->revoke');
67: $f3->route('POST @oauth_introspect: /oauth/introspect', 'SimpleID\Protocols\OAuth\OAuthModule->introspect');
68: $f3->route('GET @oauth_metadata: /.well-known/oauth-authorization-server', 'SimpleID\Protocols\OAuth\OAuthModule->metadata');
69: }
70:
71: public function __construct() {
72: parent::__construct();
73: $this->oauth = OAuthManager::instance();
74: $this->mgr = ModuleManager::instance();
75: }
76:
77: /**
78: * Run post-initialisation procedures. This event is only called in the main
79: * SimpleID invocation, and not during the upgrade process.
80: *
81: * @return void
82: */
83: public function onPostInit(GenericStoppableEvent $event) {
84: $event = new ScopeInfoCollectionEvent();
85: \Events::instance()->dispatch($event);
86:
87: self::$oauth_scope_settings = $event->getScopeInfoForType('oauth');
88: }
89:
90: /**
91: * Prepares an OAuth authorisation request for processing.
92: *
93: * This function checks the request for protocol compliance via
94: * {@link checkAuthRequest()} before passing it to {@link processAuthRequest()}
95: * for processing.
96: *
97: * @return void
98: * @see checkAuthRequest()
99: * @see processAuthRequest()
100: * @since 2.0
101: */
102: public function auth() {
103: $this->checkHttps('redirect');
104:
105: $dispatcher = \Events::instance();
106:
107: $request = new Request($this->f3->get('GET'), []);
108:
109: $this->logger->log(LogLevel::INFO, 'OAuth authorisation request: ', $request->toArray());
110:
111: $response = new Response($request);
112:
113: $resolve_event = new OAuthEvent($request, $response, 'oauth_auth_resolve');
114: $dispatcher->dispatch($resolve_event);
115: if ($response->isError()) {
116: if (isset($request['redirect_uri'])) {
117: $response->renderRedirect();
118: } else {
119: $this->fatalError($this->f3->get('intl.common.protocol_error', $response['error']), 400);
120: }
121: return;
122: }
123:
124: $this->checkAuthRequest($request, $response);
125:
126: $resolve_event = new OAuthEvent($request, $response, 'oauth_auth_check');
127: $dispatcher->dispatch($resolve_event);
128: if ($response->isError()) {
129: if (isset($request['redirect_uri'])) {
130: $response->renderRedirect();
131: } else {
132: $this->fatalError($this->f3->get('intl.common.protocol_error', $response['error']), 400);
133: }
134: return;
135: }
136:
137: $this->processAuthRequest($request, $response);
138: }
139:
140: /**
141: * Checks an OAuth authorisation request for protocol compliance.
142: *
143: * @param Request $request the original request
144: * @param Response $response the OAuth response
145: * @return void
146: * @see processAuthRequest()
147: */
148: protected function checkAuthRequest($request, $response) {
149: $store = StoreManager::instance();
150:
151: // 1. response_type (pass 1 - check that it exists)
152: if (!isset($request['response_type'])) {
153: $this->logger->log(LogLevel::ERROR, 'Protocol Error: response_type not set.');
154: $this->fatalError($this->f3->get('intl.core.oauth.missing_response_type'), 400);
155: return;
156: }
157:
158: $response_types = preg_split('/\s+/', $request['response_type']);
159: if ($response_types == false) {
160: $this->logger->log(LogLevel::ERROR, 'Protocol Error: Incorrect response_type.');
161: $this->fatalError($this->f3->get('intl.core.oauth.invalid_response_type'), 400);
162: return;
163: }
164: if (in_array('token', $response_types)) $response->setResponseMode(Response::FRAGMENT_RESPONSE_MODE);
165:
166: // 2. client_id (pass 1 - check that it exists)
167: if (!isset($request['client_id'])) {
168: $this->logger->log(LogLevel::ERROR, 'Protocol Error: client_id not set');
169: if (isset($request['redirect_uri'])) {
170: $response->setError('invalid_request', 'client_id not set')->renderRedirect();
171: } else {
172: $this->fatalError($this->f3->get('intl.core.oauth.missing_client_id'), 400);
173: }
174: return;
175: }
176:
177: /** @var \SimpleID\Protocols\OAuth\OAuthClient $client */
178: $client = $store->loadClient($request['client_id'], 'SimpleID\Protocols\OAuth\OAuthClient');
179: if ($client == NULL) {
180: $this->logger->log(LogLevel::ERROR, 'Client with client_id not found: ' . $request['client_id']);
181: if (isset($request['redirect_uri'])) {
182: $response->setError('invalid_request', 'client not found')->renderRedirect();
183: } else {
184: $this->fatalError($this->f3->get('intl.core.oauth.client_not_found'), 400);
185: }
186: return;
187: }
188:
189: // 3. redirect_uri
190: if (isset($request['redirect_uri'])) {
191: // Validate against client registration for public clients and implicit grant types
192: if (!$client->hasRedirectUri($request['redirect_uri'])) {
193: $this->logger->log(LogLevel::ERROR, 'Incorrect redirect URI: ' . $request['redirect_uri']);
194: $this->fatalError($this->f3->get('intl.core.oauth.invalid_redirect_uri'), 400);
195: return;
196: }
197: } elseif (isset($client['oauth']['redirect_uris'])) {
198: if (is_string($client['oauth']['redirect_uris'])) {
199: $response->setRedirectURI($client['oauth']['redirect_uris']);
200: } elseif (count($client['oauth']['redirect_uris']) == 1) {
201: $response->setRedirectURI($client['oauth']['redirect_uris'][0]);
202: } else {
203: $this->logger->log(LogLevel::ERROR, 'Protocol Error: redirect_uri not specified in request when multiple redirect_uris are registered');
204: $this->fatalError($this->f3->get('intl.core.oauth.ambiguous_redirect_uri'), 400);
205: return;
206: }
207: } else {
208: $this->logger->log(LogLevel::ERROR, 'Protocol Error: redirect_uri not specified in request or client registration');
209: $this->fatalError($this->f3->get('intl.core.oauth.missing_redirect_uri'), 400);
210: return;
211: }
212:
213: // 4. response_type (pass 2 - check that all are supported)
214: $event = new BaseDataCollectionEvent('oauth_response_types');
215: \Events::instance()->dispatch($event);
216:
217: $supported_response_types = $event->getResults();
218: foreach ($response_types as $response_type) {
219: if (!in_array($response_type, $supported_response_types)) {
220: $this->logger->log(LogLevel::ERROR, 'Protocol Error: unsupported response_type: ' . $response_type);
221: $response->setError('unsupported_response_type', 'unsupported response_type: ' . $response_type)->renderRedirect();
222: return;
223: }
224: }
225:
226: // 5. PKCE required for native clients - RFC 8252 section 8.1
227: if ($client->isNative() && $request->paramContains('response_type', 'code') && !isset($request['code_challenge'])) {
228: $this->logger->log(LogLevel::ERROR, 'Protocol Error: code_challenge required for native apps');
229: $response->setError('invalid_request', 'code_challenge required for native apps')->renderRedirect();
230: }
231: }
232:
233: /**
234: * Processes an OAuth authorisation request that has been prepared by {@link checkAuthRequest()}.
235: *
236: * It is important that all requests are prepared by {@link checkAuthRequest()}
237: * instead of being passed directly to this function, as this function assumes that the request
238: * has been checked for protocol compliance.
239: *
240: * @param Request $request the original request
241: * @param Response $response the OAuth response
242: * @return void
243: * @see checkAuthRequest()
244: */
245: protected function processAuthRequest($request, $response) {
246: $this->logger->log(LogLevel::INFO, 'Expanded OAuth authorisation request: ', $request->toArray());
247:
248: $core_result = $this->checkIdentity($request);
249:
250: $event = new OAuthAuthRequestEvent($request, $response);
251: $event->setResult($core_result);
252: \Events::instance()->dispatch($event);
253: $result = $event->getResult();
254:
255: switch ($result) {
256: case self::CHECKID_OK:
257: $this->logger->log(LogLevel::INFO, 'CHECKID_OK');
258:
259: if (isset($request['scope'])) {
260: $scopes = $request->paramToArray('scope');
261: } else {
262: $scopes = [ self::DEFAULT_SCOPE ];
263: }
264: $this->grantAuth($request, $response, $scopes);
265: break;
266: case self::CHECKID_APPROVAL_REQUIRED:
267: $this->logger->log(LogLevel::INFO, 'CHECKID_APPROVAL_REQUIRED');
268: if ($request->isImmediate()) {
269: $response->setError('consent_required', 'Consent required')->renderRedirect();
270: } else {
271: $this->consentForm($request, $response);
272: }
273: break;
274: case self::CHECKID_REENTER_CREDENTIALS:
275: case self::CHECKID_LOGIN_REQUIRED:
276: $this->logger->log(LogLevel::INFO, 'CHECKID_LOGIN_REQUIRED');
277: if ($request->isImmediate()) {
278: $response->setError('login_required', 'Login required')->renderRedirect();
279: } else {
280: $token = new SecurityToken();
281: $request_state = new RequestState();
282: $request_state->setRoute('/oauth/auth')->setParams($request->toArray());
283: $form_state = new FormState([
284: 'mode' => AuthManager::MODE_CREDENTIALS,
285: 'auth_skip_activity' => true
286: ]);
287: $form_state->setRequest($request);
288: if ($result == self::CHECKID_REENTER_CREDENTIALS) {
289: $auth = AuthManager::instance();
290: $user = $auth->getUser();
291: $form_state['uid'] = $user['uid'];
292: $form_state['mode'] = AuthManager::MODE_REENTER_CREDENTIALS;
293: }
294:
295: /** @var \SimpleID\Auth\AuthModule $auth_module */
296: $auth_module = $this->mgr->getModule('SimpleID\Auth\AuthModule');
297: $auth_module->loginForm([
298: 'destination' => 'continue/' . rawurlencode($token->generate($request_state))
299: ], $form_state);
300: exit;
301: }
302: break;
303: case self::CHECKID_INSUFFICIENT_TRUST:
304: $this->logger->log(LogLevel::INFO, 'CHECKID_INSUFFICIENT_TRUST');
305: $response->setError('invalid_request', 'SimpleID does not support the requested level of trust')->renderRedirect();
306: break;
307: }
308: }
309:
310:
311: /**
312: * Determines whether the current user has granted authorisation to the OAuth/OpenID Connect
313: * client.
314: *
315: * @param Request $request the OAuth authorisation request
316: * @return int one of CHECKID_OK, CHECKID_APPROVAL_REQUIRED, CHECKID_LOGIN_REQUIRED, CHECKID_INSUFFICIENT_TRUST
317: * or CHECKID_USER_DENIED
318: */
319: protected function checkIdentity($request) {
320: $auth = AuthManager::instance();
321: $store = StoreManager::instance();
322:
323: // Check 1: Is the user logged into SimpleID as any user?
324: if (!$auth->isLoggedIn()) {
325: return self::CHECKID_LOGIN_REQUIRED;
326: } else {
327: $user = $auth->getUser();
328: $uid = $user['uid'];
329: }
330:
331: // Check 2: See if the user has consents saved for this client
332: $cid = $request['client_id'];
333:
334: $client_prefs = isset($user->clients[$cid]) ? $user->clients[$cid] : NULL;
335:
336: if (isset($client_prefs['consents']['oauth'])) {
337: $consents = $client_prefs['consents']['oauth'];
338: } else {
339: return self::CHECKID_APPROVAL_REQUIRED;
340: }
341:
342: // Check 3: Compare consent given against requested scope
343: if (isset($request['scope'])) {
344: $scopes = $request->paramToArray('scope');
345: } else {
346: $scopes = [ self::DEFAULT_SCOPE ];
347: }
348: if (count(array_diff($scopes, $consents)) > 0) return self::CHECKID_APPROVAL_REQUIRED;
349:
350: return self::CHECKID_OK;
351: }
352:
353: /**
354: * Grants an authorisation request by issuing the appropriate response. The response
355: * may take in the form of an authorization code, an access token or other
356: * parameters
357: *
358: * @param Request $request the authorisation request
359: * @param Response $response the authorisation response
360: * @param array<string>|null $scopes the requested scope
361: * @return void
362: */
363: protected function grantAuth($request, $response, $scopes = NULL) {
364: $dispatcher = \Events::instance();
365: $store = StoreManager::instance();
366:
367: $user = AuthManager::instance()->getUser();
368: $client = $store->loadClient($request['client_id'], 'SimpleID\Protocols\OAuth\OAuthClient');
369: if ($scopes == NULL) {
370: if (isset($request['scope'])) {
371: $scopes = $request->paramToArray('scope');
372: } else {
373: $scopes = [ self::DEFAULT_SCOPE ];
374: }
375: }
376:
377: /** @var Authorization $authorization */
378: $authorization = $store->loadAuth(Authorization::buildID($user, $client));
379:
380: if ($authorization == null) {
381: $authorization = new Authorization($user, $client, $scopes);
382: } else {
383: $authorization->setScope($scopes);
384: }
385:
386: $result_event = new ProtocolResultEvent(self::CHECKID_OK, $user, $client);
387: $dispatcher->dispatch($result_event);
388:
389: if ($request->paramContains('response_type', 'code')) {
390: $additional = [];
391: if (isset($request['code_challenge'])) {
392: $additional['code_challenge'] = $request['code_challenge'];
393: $additional['code_challenge_method'] = (isset($request['code_challenge_method'])) ? $request['code_challenge_method'] : 'plain';
394: }
395: $response['code'] = $authorization->issueCode((isset($request['redirect_uri'])) ? $request['redirect_uri'] : NULL, NULL, $additional);
396: }
397:
398: if ($request->paramContains('response_type', 'token')) {
399: $response->loadData($authorization->issueAccessToken($scopes, SIMPLEID_SHORT_TOKEN_EXPIRES_IN));
400:
401: $token_event = new OAuthTokenGrantEvent('implicit', $authorization, $request, $response, $scopes);
402: $dispatcher->dispatch($token_event);
403: }
404:
405: $grant_auth_event = new OAuthAuthGrantEvent($authorization, $request, $response, $scopes);
406: $dispatcher->dispatch($grant_auth_event);
407:
408: $store->saveAuth($authorization);
409: $store->saveUser($user);
410:
411: $this->logger->log(LogLevel::DEBUG, 'Authorization granted: ', $response->toArray());
412:
413: $response->renderRedirect();
414: }
415:
416: /**
417: * Processes an OAuth token request.
418: *
419: * @return void
420: * @since 2.0
421: */
422: public function token() {
423: $request = new Request($this->f3->get('POST'));
424: $response = new Response($request);
425:
426: $this->checkHttps('error');
427:
428: $this->logger->log(LogLevel::INFO, 'OAuth token request: ', $request->toArray());
429:
430: if (!isset($request['grant_type'])) {
431: $this->logger->log(LogLevel::ERROR, 'Protocol Error: grant_type not set.');
432: $response->setError('invalid_request', 'grant_type not set');
433: $response->renderJSON();
434: return;
435: }
436:
437: $this->oauth->initClient();
438: $client = $this->oauth->getClient();
439:
440: if (!$this->oauth->isClientAuthenticated(true, isset($client['oauth']['token_endpoint_auth_method']) ? $client['oauth']['token_endpoint_auth_method'] : null)) {
441: $this->logger->log(LogLevel::ERROR, 'Client authentication failed');
442: $response->setError('invalid_client', 'client authentication failed');
443: $response->renderJSON();
444: return;
445: }
446:
447: $grant_types = (isset($client['oauth']['grant_types'])) ? $client['oauth']['grant_types'] : [ 'authorization_code' ];
448: if (!in_array($request['grant_type'], $grant_types)) {
449: $this->logger->log(LogLevel::ERROR, 'Grant type not registered by client');
450: $response->setError('unauthorized_client', 'Grant type not registered by client');
451: $response->renderJSON();
452: return;
453: }
454:
455: switch ($request['grant_type']) {
456: case 'authorization_code':
457: $this->tokenFromCode($request, $response);
458: break;
459: case 'refresh_token':
460: $this->tokenFromRefreshToken($request, $response);
461: break;
462: case 'password':
463: case 'client_credentials':
464: // Not allowed
465: default:
466: // Extensions can be put here.
467: $this->logger->log(LogLevel::ERROR, 'Token request failed: unsupported grant type');
468: $response->setError('unsupported_grant_type', 'grant type ' . $request['grant_type'] . ' is not supported');
469: break;
470: }
471:
472: $this->logger->log(LogLevel::DEBUG, 'Token response: ', $response->toArray());
473: $response->renderJSON();
474: }
475:
476: /**
477: * Processes an OAuth token request where an authorisation code is supplied.
478: *
479: * @param Request $request the OAuth token request
480: * @param Response $response the OAuth response
481: * @return void
482: * @since 2.0
483: */
484: protected function tokenFromCode($request, $response) {
485: // 1. Check code parameter
486: if (!isset($request['code']) || ($request['code'] == '')) {
487: $this->logger->log(LogLevel::ERROR, 'Token request failed: code not set');
488: $response->setError('invalid_request', 'code not set');
489: return;
490: }
491:
492: // 2. Load the authorization and delete all tokens with this source
493: $code = Code::decode($request['code']);
494: $authorization = $code->getAuthorization();
495: if ($authorization == null) {
496: $this->logger->log(LogLevel::ERROR, 'Token request failed: Authorisation not found or expired');
497: $response->setError('invalid_grant', 'Authorization code not found or expired');
498: return;
499: }
500: $authorization->revokeTokensFromGrant($code);
501:
502:
503: // 3. Check for validity
504: if (!$code->isValid()) {
505: $this->logger->log(LogLevel::ERROR, 'Token request failed: Authorisation code not found or expired: ' . $request['code']);
506: $response->setError('invalid_grant', 'Authorization code not found or expired');
507: return;
508: }
509:
510: // 4. Check request URI
511: if ($code->getRedirectURI()) {
512: if (!isset($request['redirect_uri']) || ($code->getRedirectURI() != $request['redirect_uri'])) {
513: $this->logger->log(LogLevel::ERROR, 'Token request failed: redirect_uri in request <' . $request['redirect_uri'] . '> does not match authorisation code <' . $code->getRedirectURI() . '>');
514: $response->setError('invalid_grant', 'redirect_uri does not match');
515: return;
516: }
517: }
518:
519: // 5. PKCE
520: $additional = $code->getAdditional();
521: if (isset($additional['code_challenge'])) {
522: if (!isset($request['code_verifier'])) {
523: $this->logger->log(LogLevel::ERROR, 'Token request failed: code_verifier not found');
524: $response->setError('invalid_grant', 'code_verifier not found');
525: return;
526: }
527:
528: $code_verified = false;
529: switch ($additional['code_challenge_method']) {
530: case 'plain':
531: $test_code_challenge = $request['code_verifier'];
532: break;
533: case 'S256':
534: $test_code_challenge = trim(strtr(base64_encode(hash('sha256', $request['code_verifier'], true)), '+/', '-_'), '=');
535: break;
536: default:
537: $this->logger->log(LogLevel::ERROR, 'Token request failed: unknown code_challenge_method: ' . $additional['code_challenge_method']);
538: $response->setError('invalid_grant', 'unknown code_challenge_method');
539: return;
540: }
541: $code_verified = $this->secureCompare($test_code_challenge, $additional['code_challenge']);
542: if (!$code_verified) {
543: $this->logger->log(LogLevel::ERROR, 'Token request failed: code_challenge in request <' . $test_code_challenge . '> does not match stored code_challenge <' . $additional['code_challenge'] . '>');
544: $response->setError('invalid_grant', 'code_verifier does not match');
545: return;
546: }
547: }
548:
549: $scope = $code->getScope();
550:
551: // If we issue, we delete the code so that it can't be used again
552: $code->clear();
553:
554: $response->loadData($authorization->issueTokens($scope, SIMPLEID_SHORT_TOKEN_EXPIRES_IN, $code));
555:
556: // Call modules
557: $event = new OAuthTokenGrantEvent('authorization_code', $authorization, $request, $response, $scope);
558: \Events::instance()->dispatch($event);
559: }
560:
561: /**
562: * Processes an OAuth refresh token request.
563: *
564: * @param Request $request the OAuth token request
565: * @param Response $response the response
566: * @return void
567: */
568: protected function tokenFromRefreshToken($request, $response) {
569: $store = StoreManager::instance();
570: $client = $this->oauth->getClient();
571:
572: if (!isset($request['refresh_token']) || ($request['refresh_token'] == '')) {
573: $this->logger->log(LogLevel::ERROR, 'Token request failed: refresh_token not set');
574: $response->setError('invalid_request', 'refresh_token not set');
575: return;
576: }
577:
578: $refresh_token = RefreshToken::decode($request['refresh_token']);
579: if (!$refresh_token->isValid()) {
580: $this->logger->log(LogLevel::ERROR, 'Token request failed: Refresh token not valid');
581: $response->setError('invalid_grant', 'Refresh token not valid');
582: return;
583: }
584:
585: $authorization = $refresh_token->getAuthorization();
586: if ($authorization->getClient()->getStoreID() != $client->getStoreID()) {
587: $this->logger->log(LogLevel::ERROR, 'Token request failed: this client (' . $client->getStoreID() . ') does not match the client stored in refresh token (' . $authorization->getClient()->getStoreID() . ')');
588: $response->setError('invalid_grant', 'this client does not match the client stored in refresh token');
589: return;
590: }
591: $authorization->revokeTokensFromGrant($refresh_token);
592:
593: $scope = $refresh_token->getScope();
594:
595: // If we issue, we delete the old refresh token so that it can't be used again
596: $refresh_token->revoke();
597: $authorization->resetAuthState();
598: $store->saveAuth($authorization);
599:
600: $response->loadData($authorization->issueTokens($scope, SIMPLEID_SHORT_TOKEN_EXPIRES_IN, $refresh_token));
601:
602: // Call modules
603: $event = new OAuthTokenGrantEvent('refresh_token', $authorization, $request, $response, $scope);
604: \Events::instance()->dispatch($event);
605: }
606:
607:
608: /**
609: * Provides a form for user authorisation of an OAuth client.
610: *
611: * @param Request $request the OAuth request
612: * @param Response $response the OAuth response
613: * @return void
614: * @since 2.0
615: */
616: protected function consentForm($request, $response) {
617: $store = StoreManager::instance();
618: $tpl = Template::instance();
619:
620: $client = $store->loadClient($request['client_id'], 'SimpleID\Protocols\OAuth\OAuthClient');
621:
622: $form_state = new FormState();
623: $form_state->setRequest($request);
624: $form_state->setResponse($response);
625:
626: $request_state = new RequestState();
627: $request_state->setParams($request->toArray());
628:
629: $application_name = $client->getDisplayName();
630: $application_type = (isset($client['oauth']['application_type'])) ? $client['oauth']['application_type'] : '';
631:
632: $this->f3->set('application_name', $application_name);
633: $this->f3->set('application_type', $application_type);
634:
635: if (isset($client['logo_url'])) {
636: $this->f3->set('logo_url', $client['logo_url']);
637: }
638:
639: if (isset($request['scope'])) {
640: $scopes = $request->paramToArray('scope');
641: } else {
642: $scopes = [ self::DEFAULT_SCOPE ];
643: }
644: usort($scopes, [ $this, 'sortScopes' ]);
645:
646: $scope_list = [];
647: foreach ($scopes as $scope) {
648: $scope_list[$scope] = (isset(self::$oauth_scope_settings[$scope]['description'])) ? self::$oauth_scope_settings[$scope]['description'] : 'scope ' . $scope;
649: }
650: $this->f3->set('scope_list', $scope_list);
651:
652: if ($client->isDynamic()) {
653: $this->f3->set('client_dynamic', 'client-dynamic');
654: }
655:
656: $client_info = [];
657: if (isset($client['oauth']['website'])) {
658: $client_info[] = $this->f3->get('intl.common.consent.website', $client['oauth']['website']);
659: }
660: if (isset($client['oauth']['policy_url'])) {
661: $client_info[] = $this->f3->get('intl.common.consent.policy_url', $client['oauth']['policy_url']);
662: }
663: if (isset($client['oauth']['tos_url'])) {
664: $client_info[] = $this->f3->get('intl.common.consent.tos_url', $client['oauth']['tos_url']);
665: }
666: if (isset($client['oauth']['contacts'])) {
667: $contacts = [];
668:
669: if (is_array($client['oauth']['contacts'])) {
670: foreach ($client['oauth']['contacts'] as $contact) {
671: $contacts[] = '<a href="mailto:' . $this->rfc3986_urlencode($contact) . '">' . $this->f3->clean($contact) . '</a>';
672: }
673: } else {
674: $contacts[] = '<a href="mailto:' . $this->rfc3986_urlencode($client['oauth']['contacts']) . '">' . $this->f3->clean($client['oauth']['contacts']) . '</a>';
675: }
676:
677: $client_info[] = $this->f3->get('intl.common.consent.contacts', implode(', ', $contacts));
678: }
679: $this->f3->set('client_info', $client_info);
680:
681: $token = new SecurityToken();
682: $this->f3->set('tk', $token->generate('oauth_consent', SecurityToken::OPTION_BIND_SESSION));
683: $this->f3->set('fs', $token->generate($form_state->encode()));
684:
685: $this->f3->set('logout_destination', '/continue/' . rawurlencode($token->generate($request_state)));
686: $this->f3->set('user_header', true);
687: $this->f3->set('title', $this->f3->get('intl.core.oauth.oauth_title'));
688: $this->f3->set('page_class', 'is-dialog-page');
689: $this->f3->set('layout', 'oauth_consent.html');
690:
691: $event = new FormBuildEvent($form_state, 'oauth_consent_form_build');
692: \Events::instance()->dispatch($event);
693: $tpl->mergeAttachments($event);
694: $this->f3->set('forms', $event->getBlocks());
695:
696: header('X-Frame-Options: DENY');
697: print $tpl->render('page.html');
698: }
699:
700:
701: /**
702: * Processes a user response from the {@link consentForm()} function.
703: *
704: * @return void
705: * @since 2.0
706: */
707: function consent() {
708: $auth = AuthManager::instance();
709: $token = new SecurityToken();
710: $store = StoreManager::instance();
711:
712: if (!$auth->isLoggedIn()) {
713: /** @var \SimpleID\Auth\AuthModule $auth_module */
714: $auth_module = $this->mgr->getModule('SimpleID\Auth\AuthModule');
715: $auth_module->loginForm();
716: return;
717: }
718: $user = $auth->getUser();
719:
720: $form_state = FormState::decode($token->getPayload($this->f3->get('POST.fs')), Request::class, Response::class);
721: /** @var Request $request */
722: $request = $form_state->getRequest();
723: /** @var Response $response */
724: $response = $form_state->getResponse();
725:
726: if (!$token->verify($this->f3->get('POST.tk'), 'oauth_consent')) {
727: $this->logger->log(LogLevel::WARNING, 'Security token ' . $this->f3->get('POST.tk') . ' invalid.');
728: $this->f3->set('message', $this->f3->get('intl.common.invalid_tk'));
729: $this->consentForm($request, $response);
730: return;
731: }
732:
733: if ($this->f3->get('POST.op') == 'deny') {
734: $response->setError('access_denied')->renderRedirect();
735: return;
736: } else {
737: $event = new FormSubmitEvent($form_state, 'oauth_consent_form_submit');
738: \Events::instance()->dispatch($event);
739:
740: $client = $store->loadClient($request['client_id'], 'SimpleID\Protocols\OAuth\OAuthClient');
741: $cid = $client->getStoreID();
742: $now = time();
743:
744: $consents = [ 'oauth' => $this->f3->get('POST.prefs.consents.oauth') ];
745:
746: if (isset($user->clients[$cid])) {
747: $prefs = $user->clients[$cid];
748: } else {
749: $prefs = [
750: 'oauth' => [],
751: 'store_id' => $client->getStoreID(),
752: 'display_name' => $client->getDisplayName(),
753: 'display_html' => $client->getDisplayHTML(),
754: 'first_time' => $now,
755: 'consents' => [],
756: ];
757: }
758:
759: $prefs['last_time'] = $now;
760: $prefs['consents'] = array_merge($prefs['consents'], $consents);
761:
762: $user->clients[$cid] = $prefs;
763: $store->saveUser($user);
764: }
765:
766: $this->processAuthRequest($request, $response);
767: }
768:
769: /**
770: * Endpoint for token revocation requests
771: *
772: * @link https://datatracker.ietf.org/doc/html/rfc7009
773: * @return void
774: */
775: public function revoke() {
776: $request = new Request($this->f3->get('POST'));
777: $response = new Response($request);
778:
779: $this->checkHttps('error');
780:
781: $this->logger->log(LogLevel::INFO, 'OAuth token revocation request: ', $request->toArray());
782:
783: $this->oauth->initClient();
784: $client = $this->oauth->getClient();
785:
786: if (!$this->oauth->isClientAuthenticated(true, isset($client['oauth']['token_endpoint_auth_method']) ? $client['oauth']['token_endpoint_auth_method'] : null)) {
787: $this->logger->log(LogLevel::ERROR, 'Client authentication failed');
788: $response->setError('invalid_client', 'client authentication failed');
789: $response->renderJSON();
790: return;
791: }
792:
793: $token = $this->inferTokenFromRequestBody($request, $response);
794: if ($response->isError()) {
795: $response->renderJSON();
796: return;
797: }
798:
799: if (($token != null) && $token->isValid()) {
800: $authorization = $token->getAuthorization();
801: if ($authorization->getClient()->getStoreID() != $client->getStoreID()) {
802: $this->logger->log(LogLevel::ERROR, 'Token revocation request failed: this client (' . $client->getStoreID() . ') does not match the client stored in token (' . $authorization->getClient()->getStoreID() . ')');
803: $response->setError('invalid_grant', 'this client does not match the client stored in token');
804: $response->renderJSON();
805: return;
806: }
807:
808: $token->revoke();
809: }
810:
811: // It does not matter what we put here, as the client is supposed to ignore
812: // the response body.
813: $this->logger->log(LogLevel::INFO, 'OAuth token revoked: ', $request['token']);
814: $response['success'] = true;
815: $response->renderJSON();
816: }
817:
818: /**
819: * Endpoint for token revocation requests
820: *
821: * @link https://datatracker.ietf.org/doc/html/rfc7009
822: * @return void
823: */
824: public function introspect() {
825: $request = new Request($this->f3->get('POST'));
826: $response = new Response($request);
827:
828: $this->checkHttps('error');
829:
830: $this->logger->log(LogLevel::INFO, 'OAuth token introspection request: ', $request->toArray());
831:
832: $this->oauth->initClient();
833: $client = $this->oauth->getClient();
834:
835: if (!$this->oauth->isClientAuthenticated(true, isset($client['oauth']['token_endpoint_auth_method']) ? $client['oauth']['token_endpoint_auth_method'] : null)) {
836: $this->logger->log(LogLevel::ERROR, 'Client authentication failed');
837: $response->setError('invalid_client', 'client authentication failed');
838: $response->renderJSON();
839: return;
840: }
841:
842: $token = $this->inferTokenFromRequestBody($request, $response);
843: if ($response->isError()) {
844: $response->renderJSON();
845: return;
846: }
847:
848: if (($token == null) || (!$token->isValid())) {
849: $this->logger->log(LogLevel::INFO, 'OAuth token introspection result: not active');
850: $response['active'] = false;
851: $response->renderJSON();
852: return;
853: }
854:
855: $authorization = $token->getAuthorization();
856: if ($authorization->getClient()->getStoreID() != $client->getStoreID()) {
857: $this->logger->log(LogLevel::ERROR, 'Token introspection request failed: this client (' . $client->getStoreID() . ') does not match the client stored in token (' . $authorization->getClient()->getStoreID() . ')');
858: $response->setError('invalid_grant', 'this client does not match the client stored in token');
859: $response->renderJSON();
860: return;
861: }
862:
863: $expiry = $token->getExpiry();
864:
865: $response['active'] = true;
866: $response['scope'] = implode(' ', $token->getScope());
867: $response['client_id'] = $client->getStoreID();
868: $response['token_type'] = $token->getType();
869: if ($expiry != null) $response['exp'] = $expiry;
870:
871: $this->logger->log(LogLevel::INFO, 'OAuth token introspection result: active');
872: $response->renderJSON();
873: }
874:
875: /**
876: * Displays the OAuth authorisation server metadata for this installation.
877: *
878: * @link https://datatracker.ietf.org/doc/html/rfc8414
879: * @return void
880: */
881: public function metadata() {
882: $dispatcher = \Events::instance();
883:
884: header('Content-Type: application/json');
885: header('Content-Disposition: inline; filename=oauth-authorization-server');
886:
887: $scope_info_event = new ScopeInfoCollectionEvent();
888: $dispatcher->dispatch($scope_info_event);
889: $scopes = $scope_info_event->getScopeInfoForType('oauth');
890:
891: $config = [
892: 'issuer' => $this->getCanonicalHost(),
893: 'authorization_endpoint' => $this->getCanonicalURL('@oauth_auth', '', 'https'),
894: 'token_endpoint' => $this->getCanonicalURL('@oauth_token', '', 'https'),
895: 'revocation_endpoint' => $this->getCanonicalURL('@oauth_revoke', '', 'https'),
896: 'introspection_endpoint' => $this->getCanonicalURL('@oauth_introspect', '', 'https'),
897: 'scopes_supported' => array_keys($scopes),
898: 'response_types_supported' => [ 'code', 'token', 'code token' ],
899: 'response_modes_supported' => Response::getResponseModesSupported(),
900: 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
901: 'token_endpoint_auth_methods_supported' => $this->oauth->getSupportedClientAuthMethods(),
902: 'revocation_endpoint_auth_methods_supported' => $this->oauth->getSupportedClientAuthMethods(),
903: 'introspection_endpoint_auth_methods_supported' => $this->oauth->getSupportedClientAuthMethods(),
904: 'code_challenge_methods_supported' => [ 'plain', 'S256' ],
905: 'service_documentation' => 'https://simpleid.org/docs/'
906: ];
907:
908: $config_event = new BaseDataCollectionEvent('oauth_metadata', BaseDataCollectionEvent::MERGE_RECURSIVE);
909: $config_event->addResult($config);
910: $dispatcher->dispatch($config_event);
911: print json_encode($config_event->getResults());
912: }
913:
914: /**
915: * @see SimpleID\Base\ScopeInfoCollectionEvent
916: * @return void
917: */
918: public function onScopeInfoCollectionEvent(ScopeInfoCollectionEvent $event) {
919: $event->addScopeInfo('oauth', [
920: self::DEFAULT_SCOPE => [
921: 'description' => $this->f3->get('intl.common.scope.id'),
922: //'required' => true
923: ]
924: ]);
925: }
926:
927: /**
928: * @return void
929: */
930: public function onOauthResponseTypes(BaseDataCollectionEvent $event) {
931: $event->addResult([ 'token', 'code' ]);
932: }
933:
934: /**
935: * @see SimpleID\API\MyHooks::revokeAppHook()
936: * @return void
937: */
938: public function onConsentRevoke(ConsentEvent $event) {
939: $cid = $event->getConsentID();
940: $auth = AuthManager::instance();
941: $store = StoreManager::instance();
942:
943: $user = $auth->getUser();
944: $client = $store->loadClient($cid, 'SimpleID\Protocols\OAuth\OAuthClient');
945:
946: $aid = Authorization::buildID($user, $client);
947:
948: /** @var Authorization $authorization */
949: $authorization = $store->loadAuth($aid);
950:
951: if ($authorization != null) {
952: $authorization->revokeAllTokens();
953: $store->deleteAuth($authorization);
954: }
955: }
956:
957: /**
958: * Encodes a URL using RFC 3986.
959: *
960: * PHP's rawurlencode function encodes a URL using RFC 1738. RFC 1738 has been
961: * updated by RFC 3986, which change the list of characters which needs to be
962: * encoded.
963: *
964: * Strictly correct encoding is required for various purposes, such as OAuth
965: * signature base strings.
966: *
967: * @param string $s the URL to encode
968: * @return string the encoded URL
969: */
970: protected function rfc3986_urlencode($s) {
971: return str_replace('%7E', '~', rawurlencode($s));
972: }
973:
974: /**
975: * Infers a token by parsing the `token` and `token_type_hint` parameters
976: * in the body of a request. If `token_type_hint` exists, then the
977: * appropriate `Token` object is created from `token`. If `token_type_hint`
978: * does not exist, it firstly attempts to create an access token, then
979: * it attempts to create a refresh token.
980: *
981: * Note that the token returned may not be valid.
982: *
983: * If an error occurs, then an appropriate error response is set using
984: * the supplied response object
985: *
986: * @param Request $request the request
987: * @param Response $response the response
988: * @return ?Token the access or refresh token, or null if no token can be found
989: */
990: protected function inferTokenFromRequestBody(Request $request, Response $response): ?Token {
991: if (!isset($request['token']) || ($request['token'] == '')) {
992: $this->logger->log(LogLevel::ERROR, 'Token operation request failed: token not set');
993: $response->setError('invalid_request', 'token not set');
994: return null;
995: }
996:
997: if (isset($request['token_type_hint'])) {
998: switch ($request['token_type_hint']) {
999: case 'access_token':
1000: $token = AccessToken::decode($request['token']);
1001: break;
1002: case 'refresh_token':
1003: $token = RefreshToken::decode($request['token']);
1004: break;
1005: default:
1006: $this->logger->log(LogLevel::ERROR, 'Token operation request failed: unsupported token type');
1007: $response->setError('unsupported_token_type', 'unsupported token type');
1008: return null;
1009: }
1010: } else {
1011: // No token_type_hint. Try access_token, then refresh_token
1012: $token = AccessToken::decode($request['token']);
1013: if (!$token->isValid()) $token = RefreshToken::decode($request['token']);
1014: if (!$token->isValid()) $token = null;
1015: }
1016:
1017: return $token;
1018: }
1019:
1020: /**
1021: * A callback function for use by usort() to sort scopes to be displayed on
1022: * a consent form.
1023: *
1024: * This function determines the sort order as follows:
1025: *
1026: * 1. If the relevant entry has a
1027: * key called `required` and is set to true, this scope is placed first
1028: * 2. If the relevant entry has a
1029: * key called `weight`, it is sorted using that weight.
1030: * 3. Otherwise, scopes are sorted in alphabetical order
1031: *
1032: * @param string $a
1033: * @param string $b
1034: * @return int
1035: * @since 2.0
1036: */
1037: static function sortScopes($a, $b) {
1038: $a_required = (isset(self::$oauth_scope_settings[$a]['required'])) ? self::$oauth_scope_settings[$a]['required'] : false;
1039: $b_required = (isset(self::$oauth_scope_settings[$b]['required'])) ? self::$oauth_scope_settings[$b]['required'] : false;
1040:
1041: if ($a_required && !$b_required) return -1;
1042: if ($b_required && !$a_required) return 1;
1043:
1044: $a_weight= (isset(self::$oauth_scope_settings[$a]['weight'])) ? self::$oauth_scope_settings[$a]['weight'] : 0;
1045: $b_weight = (isset(self::$oauth_scope_settings[$b]['weight'])) ? self::$oauth_scope_settings[$b]['weight'] : 0;
1046:
1047: if ($a_weight < $b_weight) return -1;
1048: if ($a_weight > $b_weight) return 1;
1049:
1050: return strcasecmp($a, $b);
1051: }
1052:
1053: }
1054: ?>
1055: