3939use models \exceptions \ValidationException ;
4040use Utils \Db \ITransactionService ;
4141use models \exceptions \EntityNotFoundException ;
42+ use Utils \Exceptions \UnacquiredLockException ;
4243use Utils \Http \HttpUtils ;
4344use Utils \Services \IAuthService ;
45+ use Utils \Services \ILockManagerService ;
46+ use Closure ;
4447/**
4548 * Class ClientService
4649 * @package Services\OAuth2
4750 */
4851final class ClientService extends AbstractService implements IClientService
4952{
53+ /**
54+ * SHORTCUT: a single global lock name serializes ALL Native client create()/update() calls that
55+ * touch a custom URI scheme, not just the ones that would actually collide on the same scheme -
56+ * acceptable given self-service client registration is low-throughput. Upgrade trigger: move to
57+ * per-scheme locks (sorted acquisition order to avoid deadlock) if native client registration
58+ * volume makes this a bottleneck.
59+ */
60+ const NATIVE_CUSTOM_SCHEME_REGISTRATION_LOCK = 'client.native.custom_scheme.registration ' ;
61+
62+ /**
63+ * seconds - long enough to cover one create()/update() transaction, short enough that a crashed
64+ * request doesn't block native client scheme registration for long.
65+ */
66+ const NATIVE_CUSTOM_SCHEME_REGISTRATION_LOCK_LIFETIME = 30 ;
67+
5068 /**
5169 * @var IAuthService
5270 */
@@ -74,6 +92,11 @@ final class ClientService extends AbstractService implements IClientService
7492 */
7593 private $ scope_repository ;
7694
95+ /**
96+ * @var ILockManagerService
97+ */
98+ private $ lock_manager_service ;
99+
77100 /**
78101 * ClientService constructor.
79102 * @param IUserRepository $user_repository
@@ -83,6 +106,7 @@ final class ClientService extends AbstractService implements IClientService
83106 * @param IClientCredentialGenerator $client_credential_generator
84107 * @param IApiScopeRepository $scope_repository
85108 * @param ITransactionService $tx_service
109+ * @param ILockManagerService $lock_manager_service
86110 */
87111 public function __construct
88112 (
@@ -92,7 +116,8 @@ public function __construct
92116 IApiScopeService $ scope_service ,
93117 IClientCredentialGenerator $ client_credential_generator ,
94118 IApiScopeRepository $ scope_repository ,
95- ITransactionService $ tx_service
119+ ITransactionService $ tx_service ,
120+ ILockManagerService $ lock_manager_service
96121 )
97122 {
98123 parent ::__construct ($ tx_service );
@@ -102,6 +127,31 @@ public function __construct
102127 $ this ->client_credential_generator = $ client_credential_generator ;
103128 $ this ->client_repository = $ client_repository ;
104129 $ this ->scope_repository = $ scope_repository ;
130+ $ this ->lock_manager_service = $ lock_manager_service ;
131+ }
132+
133+ /**
134+ * Closes the TOCTOU race in IClientRepository::hasCustomSchemeRegisteredOnAnotherClientThan():
135+ * without this, two concurrent create()/update() calls can both pass the "is this scheme already
136+ * registered" uniqueness check before either transaction commits, letting two different clients
137+ * claim the same custom scheme - defeating the OS-level interception-prevention guarantee the
138+ * uniqueness check exists for.
139+ *
140+ * @param Closure $fn
141+ * @return IEntity
142+ * @throws ValidationException
143+ */
144+ private function withNativeCustomSchemeLock (Closure $ fn ): IEntity
145+ {
146+ try {
147+ return $ this ->lock_manager_service ->lock (
148+ self ::NATIVE_CUSTOM_SCHEME_REGISTRATION_LOCK ,
149+ $ fn ,
150+ self ::NATIVE_CUSTOM_SCHEME_REGISTRATION_LOCK_LIFETIME
151+ );
152+ } catch (UnacquiredLockException $ ex ) {
153+ throw new ValidationException ('another native client custom URI scheme registration is in progress, please retry. ' );
154+ }
105155 }
106156
107157
@@ -262,43 +312,50 @@ private function assertNativeCustomSchemesAllowed(array $payload, int $exclude_c
262312 */
263313 public function create (array $ payload ):IEntity
264314 {
315+ $ do_create = function () use ($ payload ) {
316+ return $ this ->tx_service ->transaction (function () use ($ payload ) {
265317
266- return $ this ->tx_service ->transaction (function () use ($ payload ) {
267-
268- $ current_user = $ this ->auth_service ->getCurrentUser ();
318+ $ current_user = $ this ->auth_service ->getCurrentUser ();
269319
270- $ app_name = trim ($ payload ['app_name ' ]);
320+ $ app_name = trim ($ payload ['app_name ' ]);
271321
272- if ($ this ->client_repository ->getByApplicationName ($ app_name ) != null ){
273- throw new ValidationException ('there is already another application with that name, please choose another one. ' );
274- }
322+ if ($ this ->client_repository ->getByApplicationName ($ app_name ) != null ){
323+ throw new ValidationException ('there is already another application with that name, please choose another one. ' );
324+ }
275325
276- // same scheme deny-list + cross-client uniqueness rule update() enforces (only reachable
277- // for native clients, where the runtime https gate is relaxed) - now covers redirect_uris too.
278- if (($ payload ['application_type ' ] ?? null ) === IClient::ApplicationType_Native) {
279- $ this ->assertNativeCustomSchemesAllowed ($ payload );
280- }
326+ // same scheme deny-list + cross-client uniqueness rule update() enforces (only reachable
327+ // for native clients, where the runtime https gate is relaxed) - now covers redirect_uris too.
328+ if (($ payload ['application_type ' ] ?? null ) === IClient::ApplicationType_Native) {
329+ $ this ->assertNativeCustomSchemesAllowed ($ payload );
330+ }
281331
282- $ client = ClientFactory::build ($ payload );
283- $ client = $ this ->client_credential_generator ->generate ($ client );
284-
285- if (isset ($ payload ['admin_users ' ]) && is_array ($ payload ['admin_users ' ])) {
286- $ admin_users = $ payload ['admin_users ' ];
287- //add admin users
288- foreach ($ admin_users as $ user_id ) {
289- $ user = $ this ->user_repository ->getById (intval ($ user_id ));
290- if (is_null ($ user )) throw new EntityNotFoundException (sprintf ('user %s not found. ' , $ user_id ));
291- if (!$ user instanceof User) continue ;
292- $ client ->addAdminUser ($ user );
332+ $ client = ClientFactory::build ($ payload );
333+ $ client = $ this ->client_credential_generator ->generate ($ client );
334+
335+ if (isset ($ payload ['admin_users ' ]) && is_array ($ payload ['admin_users ' ])) {
336+ $ admin_users = $ payload ['admin_users ' ];
337+ //add admin users
338+ foreach ($ admin_users as $ user_id ) {
339+ $ user = $ this ->user_repository ->getById (intval ($ user_id ));
340+ if (is_null ($ user )) throw new EntityNotFoundException (sprintf ('user %s not found. ' , $ user_id ));
341+ if (!$ user instanceof User) continue ;
342+ $ client ->addAdminUser ($ user );
343+ }
293344 }
294- }
295345
296- $ client ->setOwner ($ current_user );
346+ $ client ->setOwner ($ current_user );
297347
298- $ this ->client_repository ->add ($ client );
348+ $ this ->client_repository ->add ($ client );
299349
300- return $ client ;
301- });
350+ return $ client ;
351+ });
352+ };
353+
354+ if (($ payload ['application_type ' ] ?? null ) === IClient::ApplicationType_Native) {
355+ return $ this ->withNativeCustomSchemeLock ($ do_create );
356+ }
357+
358+ return $ do_create ();
302359 }
303360
304361
@@ -311,86 +368,93 @@ public function create(array $payload):IEntity
311368 */
312369 public function update (int $ id , array $ payload ):IEntity
313370 {
371+ $ do_update = function () use ($ id , $ payload ) {
372+ return $ this ->tx_service ->transaction (function () use ($ id , $ payload ) {
314373
315- return $ this ->tx_service ->transaction (function () use ($ id , $ payload ) {
316-
317- $ editing_user = $ this ->auth_service ->getCurrentUser ();
374+ $ editing_user = $ this ->auth_service ->getCurrentUser ();
318375
319- $ client = $ this ->client_repository ->getById ($ id );
376+ $ client = $ this ->client_repository ->getById ($ id );
320377
321- if (is_null ($ client ) || !$ client instanceof Client) {
322- throw new EntityNotFoundException (sprintf ('client id %s does not exists. ' , $ id ));
323- }
324- $ app_name = isset ($ payload ['app_name ' ]) ? trim ($ payload ['app_name ' ]) : null ;
325- if (!empty ($ app_name )) {
326- $ old_client = $ this ->client_repository ->getByApplicationName ($ app_name );
327- if (!is_null ($ old_client ) && $ old_client ->getId () !== $ client ->getId ())
328- throw new ValidationException ('there is already another application with that name, please choose another one. ' );
329- }
330- $ current_app_type = $ client ->getApplicationType ();
331- if ($ current_app_type !== $ payload ['application_type ' ])
332- {
333- throw new ValidationException ('application type does not match. ' );
334- }
378+ if (is_null ($ client ) || !$ client instanceof Client) {
379+ throw new EntityNotFoundException (sprintf ('client id %s does not exists. ' , $ id ));
380+ }
381+ $ app_name = isset ($ payload ['app_name ' ]) ? trim ($ payload ['app_name ' ]) : null ;
382+ if (!empty ($ app_name )) {
383+ $ old_client = $ this ->client_repository ->getByApplicationName ($ app_name );
384+ if (!is_null ($ old_client ) && $ old_client ->getId () !== $ client ->getId ())
385+ throw new ValidationException ('there is already another application with that name, please choose another one. ' );
386+ }
387+ $ current_app_type = $ client ->getApplicationType ();
388+ if ($ current_app_type !== $ payload ['application_type ' ])
389+ {
390+ throw new ValidationException ('application type does not match. ' );
391+ }
335392
336- ClientFactory::populate ($ client , $ payload );
393+ ClientFactory::populate ($ client , $ payload );
337394
338- // validate uris
339- switch ($ client ->getApplicationType ()) {
340- case IClient::ApplicationType_Native: {
341- // redirect_uris, allowed_origins, and post_logout_redirect_uris all share the same
342- // scheme deny-list + cross-client uniqueness rule; assertNativeCustomSchemesAllowed
343- // validates whichever of the three are present in the payload.
344- $ this ->assertNativeCustomSchemesAllowed ($ payload , $ id );
345- }
346- break ;
347- case IClient::ApplicationType_Web_App:
348- case IClient::ApplicationType_JS_Client: {
349- if (isset ($ payload ['redirect_uris ' ])){
350- if (!empty ($ payload ['redirect_uris ' ])) {
351- $ redirect_uris = explode (', ' , $ payload ['redirect_uris ' ]);
352- foreach ($ redirect_uris as $ uri ) {
395+ // validate uris
396+ switch ($ client ->getApplicationType ()) {
397+ case IClient::ApplicationType_Native: {
398+ // redirect_uris, allowed_origins, and post_logout_redirect_uris all share the same
399+ // scheme deny-list + cross-client uniqueness rule; assertNativeCustomSchemesAllowed
400+ // validates whichever of the three are present in the payload.
401+ $ this ->assertNativeCustomSchemesAllowed ($ payload , $ id );
402+ }
403+ break ;
404+ case IClient::ApplicationType_Web_App:
405+ case IClient::ApplicationType_JS_Client: {
406+ if (isset ($ payload ['redirect_uris ' ])){
407+ if (!empty ($ payload ['redirect_uris ' ])) {
408+ $ redirect_uris = explode (', ' , $ payload ['redirect_uris ' ]);
409+ foreach ($ redirect_uris as $ uri ) {
410+ $ uri = @parse_url ($ uri );
411+ if (!isset ($ uri ['scheme ' ])) {
412+ throw new ValidationException ('invalid scheme on redirect uri. ' );
413+ }
414+ if (!HttpUtils::isHttpsSchema ($ uri ['scheme ' ])) {
415+ throw new ValidationException (sprintf ('scheme %s:// is invalid. ' , $ uri ['scheme ' ]));
416+ }
417+ }
418+ }
419+ }
420+ if ($ client ->getApplicationType () === IClient::ApplicationType_JS_Client && isset ($ payload ['allowed_origins ' ]) &&!empty ($ payload ['allowed_origins ' ])){
421+ $ allowed_origins = explode (', ' , $ payload ['allowed_origins ' ]);
422+ foreach ($ allowed_origins as $ uri ) {
353423 $ uri = @parse_url ($ uri );
354424 if (!isset ($ uri ['scheme ' ])) {
355- throw new ValidationException ('invalid scheme on redirect uri. ' );
425+ throw new ValidationException ('invalid scheme on allowed origin uri. ' );
356426 }
357427 if (!HttpUtils::isHttpsSchema ($ uri ['scheme ' ])) {
358428 throw new ValidationException (sprintf ('scheme %s:// is invalid. ' , $ uri ['scheme ' ]));
359429 }
360430 }
361431 }
362432 }
363- if ($ client ->getApplicationType () === IClient::ApplicationType_JS_Client && isset ($ payload ['allowed_origins ' ]) &&!empty ($ payload ['allowed_origins ' ])){
364- $ allowed_origins = explode (', ' , $ payload ['allowed_origins ' ]);
365- foreach ($ allowed_origins as $ uri ) {
366- $ uri = @parse_url ($ uri );
367- if (!isset ($ uri ['scheme ' ])) {
368- throw new ValidationException ('invalid scheme on allowed origin uri. ' );
369- }
370- if (!HttpUtils::isHttpsSchema ($ uri ['scheme ' ])) {
371- throw new ValidationException (sprintf ('scheme %s:// is invalid. ' , $ uri ['scheme ' ]));
372- }
373- }
374- }
433+ break ;
375434 }
376- break ;
377- }
378435
379- if (isset ($ payload ['admin_users ' ]) && is_array ($ payload ['admin_users ' ])) {
380- $ admin_users = $ payload ['admin_users ' ];
381- //add admin users
382- $ client ->removeAllAdminUsers ();
383- foreach ($ admin_users as $ user_id ) {
384- $ user = $ this ->user_repository ->getById (intval ($ user_id ));
385- if (is_null ($ user )) throw new EntityNotFoundException (sprintf ('user %s not found. ' , $ user_id ));
386- if (!$ user instanceof User) continue ;
387- $ client ->addAdminUser ($ user );
436+ if (isset ($ payload ['admin_users ' ]) && is_array ($ payload ['admin_users ' ])) {
437+ $ admin_users = $ payload ['admin_users ' ];
438+ //add admin users
439+ $ client ->removeAllAdminUsers ();
440+ foreach ($ admin_users as $ user_id ) {
441+ $ user = $ this ->user_repository ->getById (intval ($ user_id ));
442+ if (is_null ($ user )) throw new EntityNotFoundException (sprintf ('user %s not found. ' , $ user_id ));
443+ if (!$ user instanceof User) continue ;
444+ $ client ->addAdminUser ($ user );
445+ }
388446 }
389- }
390447
391- $ client ->setEditedBy ($ editing_user );
392- return $ client ;
393- });
448+ $ client ->setEditedBy ($ editing_user );
449+ return $ client ;
450+ });
451+ };
452+
453+ if (($ payload ['application_type ' ] ?? null ) === IClient::ApplicationType_Native) {
454+ return $ this ->withNativeCustomSchemeLock ($ do_update );
455+ }
456+
457+ return $ do_update ();
394458 }
395459
396460 /**
0 commit comments