1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2012-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\Connect;
24:
25: use Psr\Log\LogLevel;
26: use SimpleID\Crypt\Random;
27: use SimpleID\Module;
28: use SimpleID\Protocols\HTTPResponse;
29: use SimpleID\Protocols\OAuth\Authorization;
30: use SimpleID\Protocols\OAuth\OAuthProtectedResource;
31: use SimpleID\Protocols\OAuth\Response;
32: use SimpleID\Store\StoreManager;
33: use SimpleID\Util\RateLimiter;
34: use SimpleID\Util\Events\BaseDataCollectionEvent;
35:
36: /**
37: * Module implementing the OpenID Connect Dynamic Client Registration
38: * specification.
39: *
40: * @link http://openid.net/specs/openid-connect-registration-1_0.html
41: */
42: class ConnectClientRegistrationModule extends OAuthProtectedResource {
43:
44: const CLIENT_REGISTRATION_INIT_SCOPE = 'tag:simpleid.sf.net,2014:client_register:init';
45: const CLIENT_REGISTRATION_ACCESS_SCOPE = 'tag:simpleid.sf.net,2014:client_register:access';
46:
47: /** @var array<string, string>|null */
48: static protected $metadata_map = NULL;
49:
50: static function init($f3) {
51: $f3->route('POST @connect_client_register: /connect/client', 'SimpleID\Protocols\Connect\ConnectClientRegistrationModule->register');
52: $f3->map('/connect/client/@client_id', 'SimpleID\Protocols\Connect\ConnectClientRegistrationModule');
53: }
54:
55: public function __construct() {
56: parent::__construct();
57:
58: if (self::$metadata_map == NULL) {
59: self::$metadata_map = [
60: 'client_name' => 'client_name',
61: 'client_uri' => 'client_uri',
62: 'client_secret' => 'oauth.client_secret',
63: 'redirect_uris' => 'oauth.redirect_uris',
64: 'application_type' => 'oauth.application_type',
65: 'token_endpoint_auth_method' => 'oauth.token_endpoint_auth_method',
66: 'response_types' => 'oauth.response_types',
67: 'grant_types' => 'oauth.grant_types',
68: 'contacts' => 'oauth.contacts',
69: 'logo_uri' => 'oauth.logo_uri',
70: 'policy_uri' => 'oauth.policy_uri',
71: 'tos_uri' => 'oauth.tos_uri',
72: 'jwk_uri' => 'oauth.jwk_uri',
73: 'jwks' => 'oauth.jwks',
74: 'sector_identifier_uri' => 'connect.sector_identifier_uri',
75: 'subject_type' => 'connect.subject_type',
76: 'id_token_signed_response_alg' => 'connect.id_token_signed_response_alg',
77: 'id_token_encrypted_response_alg' => 'connect.id_token_encrypted_response_alg',
78: 'id_token_encrypted_response_enc' => 'connect.id_token_encrypted_response_enc',
79: 'userinfo_signed_response_alg' => 'connect.userinfo_signed_response_alg',
80: 'userinfo_encrypted_response_alg' => 'connect.userinfo_encrypted_response_alg',
81: 'userinfo_encrypted_response_enc' => 'connect.userinfo_encrypted_response_enc',
82: 'request_object_signing_alg' => 'connect.request_object_signing_alg',
83: 'request_object_encryption_alg' => 'connect.request_object_encryption_alg',
84: 'request_object_encryption_enc' => 'connect.request_object_encryption_enc',
85: 'token_endpoint_auth_signing_alg' => 'connect.token_endpoint_auth_signing_alg',
86: 'default_max_age' => 'connect.default_max_age',
87: 'require_auth_time' => 'connect.require_auth_time',
88: 'default_acr_values' => 'connect.default_acr_values',
89: 'initiate_login_uri' => 'connect.initiate_login_uri',
90: 'request_uris' => 'connect.request_uris',
91: 'post_logout_redirect_uris' => 'connect.post_logout_redirect_uris',
92: ];
93: }
94: }
95:
96: /**
97: * Registration endpoint
98: *
99: * @return void
100: */
101: public function register() {
102: $rand = new Random();
103: $response = new Response();
104:
105: $this->checkHttps('error');
106: $this->logger->log(LogLevel::DEBUG, 'SimpleID\Protocols\Connect\ConnectClientRegistrationModule->register');
107:
108: // Access token OR rate limit
109: if (!$this->isTokenAuthorized(self::CLIENT_REGISTRATION_INIT_SCOPE)) {
110: $limiter = new RateLimiter('connect_register');
111:
112: if (!$limiter->throttle()) {
113: header('Retry-After: ' . $limiter->getInterval());
114: // We never display a log for rate limit errors
115: $response->setError('invalid_request', 'client has been blocked from making further requests')->renderJSON(429);
116: return;
117: }
118: }
119:
120: if (!$this->f3->exists('BODY')) {
121: $response->setError('invalid_request')->renderJSON();
122: return;
123: }
124:
125: $request = json_decode($this->f3->get('BODY'), true);
126: if ($request == null) {
127: $response->setError('invalid_request', 'unable to parse body')->renderJSON();
128: return;
129: }
130:
131: if (!isset($request['redirect_uris'])) {
132: $response->setError('invalid_redirect_uri', 'redirect_uris missing')->renderJSON();
133: return;
134: }
135:
136: // Verify redirect_uri based on application_type
137: $application_type = (isset($request['application_type'])) ? $request['application_type'] : 'web';
138: $grant_types = (isset($request['grant_types'])) ? $request['grant_types'] : [ 'authorization_code' ];
139:
140: foreach ($request['redirect_uris'] as $redirect_uri) {
141: $parts = parse_url($redirect_uri);
142: if ($parts == false) continue;
143:
144: if (isset($parts['fragment'])) {
145: $response->setError('invalid_redirect_uri', 'redirect_uris cannot contain a fragment')->renderJSON();
146: return;
147: }
148:
149: if (($application_type == 'web') && in_array('implicit', $grant_types)) {
150: if ((strtolower($parts['scheme']) != 'https') || ($parts['host'] == '127.0.0.1') || ($parts['host'] == '[::1]')) {
151: $response->setError('invalid_redirect_uri', 'implicit grant type must use https URIs')->renderJSON();
152: return;
153: }
154: } elseif ($application_type == 'native') {
155: // Native Clients MUST only register redirect_uris using custom URI schemes or URLs using the http: scheme with localhost as the hostname.
156: // Authorization Servers MAY place additional constraints on Native Clients.
157: // Authorization Servers MAY reject Redirection URI values using the http scheme, other than the localhost case for Native Clients.
158: // The Authorization Server MUST verify that all the registered redirect_uris conform to these constraints. This prevents sharing a Client ID across different types of Clients.
159: if (((strtolower($parts['scheme']) == 'http') && (($parts['host'] != '127.0.0.1') && ($parts['host'] != '[::1]')))
160: || (strtolower($parts['scheme']) == 'https')) {
161: $response->setError('invalid_redirect_uri', 'native clients cannot use https URIs or http URIs with non-loopback addresses')->renderJSON();
162: return;
163: }
164: }
165: }
166:
167: // Verify sector_identifier_url
168: $subject_type = (isset($request['subject_type'])) ? $request['subject_type'] : 'public';
169: if (isset($request['sector_identifier_uri'])) {
170: if (!$this->verifySectorIdentifier($request['sector_identifier_uri'], $request['redirect_uris'])) {
171: $response->setError('invalid_client_metadata', 'cannot verify sector_identifier_uri')->renderJSON();
172: return;
173: }
174: }
175:
176: $client = new ConnectDynamicClient();
177: $client_id = $client->getStoreID();
178:
179: // Map data
180: foreach ($request as $name => $value) {
181: $parts = explode('#', $name, 2);
182: $client_path = (isset(self::$metadata_map[$parts[0]])) ? self::$metadata_map[$parts[0]] : 'connect.' . $parts[0];
183: if (isset($parts[1])) $client_path .= '#' . $parts[1];
184: $client->set($client_path, $value);
185: }
186:
187: $client->fetchJWKs();
188:
189: $response->loadData($request);
190: $response->loadData([
191: 'client_id' => $client->getStoreID(),
192: 'registration_client_uri' => $this->getCanonicalURL('connect/client/' . $client->getStoreID()),
193: 'client_id_issued_at' => time(),
194: ]);
195:
196: if ($client['oauth']['token_endpoint_auth_method'] != 'none') {
197: $client->set('oauth.client_secret', $rand->secret());
198: $response['client_secret'] = $client['oauth']['client_secret'];
199: $response['client_secret_expires_at'] = 0;
200: }
201:
202: $store = StoreManager::instance();
203: $store->saveClient($client);
204:
205: $this->logger->log(LogLevel::INFO, 'Created dynamic client: ' . $client_id);
206:
207: $auth = new Authorization($client, $client, self::CLIENT_REGISTRATION_ACCESS_SCOPE);
208: $store->saveAuth($auth);
209: $token = $auth->issueAccessToken([ self::CLIENT_REGISTRATION_ACCESS_SCOPE ]);
210: $response['registration_access_token'] = $token['access_token'];
211:
212: $this->f3->status(201);
213: $response->renderJSON();
214: }
215:
216: /**
217: * Configuration endpoint
218: *
219: * @return void
220: */
221: public function get() {
222: $this->checkHttps('error');
223: $client_id = $this->f3->get('PARAMS.client_id');
224:
225: if (!$this->isTokenAuthorized(self::CLIENT_REGISTRATION_ACCESS_SCOPE)
226: || ($this->getAccessToken()->getAuthorization()->getClient()->getStoreID() != $client_id)) {
227: $this->unauthorizedError('invalid_token');
228: return;
229: }
230:
231: $store = StoreManager::instance();
232: $client = $store->loadClient($client_id);
233:
234: if (($client == NULL) || !is_a($client, 'SimpleID\Protocols\Connect\ConnectDynamicClient')) {
235: $this->fatalError($this->f3->get('intl.common.not_found'), 404);
236: return;
237: }
238:
239: header('Content-Type: application/json');
240: header('Content-Disposition: inline');
241: print json_encode($client->getDynamicClientInfo());
242: }
243:
244: /**
245: * @return void
246: */
247: public function onOauthMetadata(BaseDataCollectionEvent $event) {
248: $event->addResult([ 'registration_endpoint' => $this->getCanonicalURL('@connect_client_register') ]);
249: }
250:
251: /**
252: * Verifies a sector identifier URI.
253: *
254: * This function retrieves the JSON document specified by `$sector_identifier_uri` and checks
255: * whether the URIs in that document are contained in `$expected_redirect_uris`
256: *
257: * @param string $sector_identifier_uri the sector identifier URI
258: * @param array<string> $expected_redirect_uris an array of URIs that the document in `$sector_identifier_uri`
259: * is expected to match
260: * @return bool true if the sector identifier is verified
261: */
262: protected function verifySectorIdentifier($sector_identifier_uri, $expected_redirect_uris) {
263: $web = \Web::instance();
264:
265: $this->logger->log(LogLevel::INFO, 'OAuth dynamic client registration request: verifying OpenID Connect sector_identifier_uri ' . $sector_identifier_uri);
266:
267: if (parse_url($sector_identifier_uri, PHP_URL_SCHEME) != 'https') {
268: $this->logger->log(LogLevel::ERROR, 'Not https:' . $sector_identifier_uri);
269: return false;
270: }
271:
272: $response = new HTTPResponse($web->request($sector_identifier_uri, [ 'headers' => [ 'Accept' => 'application/json' ] ]));
273:
274: if ($response->isHttpError()) {
275: $this->logger->log(LogLevel::ERROR, 'Cannot retrieve sector_identifier_uri:' . $sector_identifier_uri);
276: return false;
277: }
278:
279: $test_redirect_uris = json_decode($response->getBody(), true);
280: if ($test_redirect_uris == NULL) {
281: $this->logger->log(LogLevel::ERROR, 'Invalid sector_identifier_uri: not valid JSON');
282: return false;
283: } elseif ((count(array_diff($expected_redirect_uris, $test_redirect_uris)) > 0) || (count(array_diff($test_redirect_uris, $expected_redirect_uris)) > 0)) {
284: $this->logger->log(LogLevel::ERROR, 'Redirect URIs in sector_identifier_uri do not match redirect_uris');
285: return false;
286: } else {
287: $this->logger->log(LogLevel::DEBUG, 'sector_identifier_uri verified');
288: return true;
289: }
290: }
291: }
292:
293:
294: ?>
295: