diff --git a/backend/common-cdk/common_constructs/user_pool.py b/backend/common-cdk/common_constructs/user_pool.py index 8ae51c7301..5c37f1489c 100644 --- a/backend/common-cdk/common_constructs/user_pool.py +++ b/backend/common-cdk/common_constructs/user_pool.py @@ -108,6 +108,7 @@ def __init__( # pylint: disable=too-many-arguments ) self.security_profile = security_profile + self.environment_name = environment_name # Configure notification emails if provided self.notification_from_email = notification_from_email @@ -256,6 +257,7 @@ def add_ui_client( read_attributes: ClientAttributes, write_attributes: ClientAttributes, ui_scopes: list[OAuthScope] = None, + callback_path: str = '/auth/callback', ): """ Creates an app client for the UI to authenticate with the user pool. @@ -265,15 +267,31 @@ def add_ui_client( :param read_attributes: The attributes that the UI can read. :param write_attributes: The attributes that the UI can write. :param ui_scopes: OAuth scopes that are allowed with this client + :param callback_path: The OAuth redirect path the UI uses for this client. Each user scope/compact has its own + callback page (e.g. '/auth/callback/staff/jcc'), so this must match the redirect_uri the UI sends. """ + # localhost redirects are only ever appropriate for non-production environments. Fail loudly if a production + # environment is misconfigured (e.g. an SSM parameter accidentally set to 'allow_local_ui: true') rather than + # silently registering a localhost URL on the production app client. + if self.environment_name == 'prod' and environment_context.get('allow_local_ui', False): + raise ValueError("'allow_local_ui' must not be enabled in the production environment") + # Defensive fallback: even if the guard above is ever refactored, never allow localhost redirects in production. + allow_local_ui = environment_context.get('allow_local_ui', False) and self.environment_name != 'prod' + callback_urls = [] if ui_domain_name is not None: - callback_urls.append(f'https://{ui_domain_name}/auth/callback') + callback_urls.append(f'https://{ui_domain_name}{callback_path}') + # TODO - remove after cutover to custom callback paths is deployed #noqa: FIX002 + if callback_path != '/auth/callback': + callback_urls.append(f'https://{ui_domain_name}/auth/callback') # This toggle will allow front-end devs to point their local UI at this environment's user pool to support # authenticated actions. - if environment_context.get('allow_local_ui', False): + if allow_local_ui: local_ui_port = environment_context.get('local_ui_port', '3018') - callback_urls.append(f'http://localhost:{local_ui_port}/auth/callback') + callback_urls.append(f'http://localhost:{local_ui_port}{callback_path}') + # TODO - remove after cutover to custom callback paths is deployed #noqa: FIX002 + if callback_path != '/auth/callback': + callback_urls.append(f'http://localhost:{local_ui_port}/auth/callback') if not callback_urls: raise ValueError( "This app requires a callback url for its authentication path. Either provide 'domain_name' or set " @@ -287,7 +305,7 @@ def add_ui_client( logout_urls.append(f'https://{ui_domain_name}/Logout') # This toggle will allow front-end devs to point their local UI at this environment's user pool to support # authenticated actions. - if environment_context.get('allow_local_ui', False): + if allow_local_ui: local_ui_port = environment_context.get('local_ui_port', '3018') logout_urls.append(f'http://localhost:{local_ui_port}/Login') logout_urls.append(f'http://localhost:{local_ui_port}/Dashboard') diff --git a/backend/common-cdk/tests/test_user_pool.py b/backend/common-cdk/tests/test_user_pool.py index 09b53efc8e..9df034c2da 100644 --- a/backend/common-cdk/tests/test_user_pool.py +++ b/backend/common-cdk/tests/test_user_pool.py @@ -297,6 +297,85 @@ def test_ui_client_requires_callback_url(self): write_attributes=None, ) + def test_ui_client_uses_default_callback_path(self): + pool = _make_pool(self.stack) + pool.add_ui_client( + ui_domain_name='app.example.com', + environment_context={'allow_local_ui': True, 'local_ui_port': '3000'}, + read_attributes=None, + write_attributes=None, + ) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + { + 'CallbackURLs': [ + 'https://app.example.com/auth/callback', + 'http://localhost:3000/auth/callback', + ], + }, + ) + + def test_ui_client_uses_custom_callback_path(self): + pool = _make_pool(self.stack) + pool.add_ui_client( + ui_domain_name='app.example.com', + environment_context={'allow_local_ui': True, 'local_ui_port': '3000'}, + read_attributes=None, + write_attributes=None, + callback_path='/auth/callback/staff/jcc', + ) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + { + 'CallbackURLs': [ + 'https://app.example.com/auth/callback/staff/jcc', + 'https://app.example.com/auth/callback', + 'http://localhost:3000/auth/callback/staff/jcc', + 'http://localhost:3000/auth/callback', + ], + }, + ) + + def test_ui_client_raises_when_local_ui_allowed_in_prod(self): + # A production environment must never enable localhost redirects. If an SSM parameter is accidentally set to + # 'allow_local_ui: true' for prod, synthesis should fail loudly rather than register a localhost callback. + pool = _make_pool(self.stack, environment_name='prod') + with self.assertRaisesRegex(ValueError, 'allow_local_ui'): + pool.add_ui_client( + ui_domain_name='app.example.com', + environment_context={'allow_local_ui': True, 'local_ui_port': '3000'}, + read_attributes=None, + write_attributes=None, + callback_path='/auth/callback/staff/jcc', + ) + + def test_ui_client_prod_excludes_localhost(self): + # Even when the guard is not tripped (flag absent), prod must only ever register its hosted-domain redirects, + # never localhost. + pool = _make_pool(self.stack, environment_name='prod') + pool.add_ui_client( + ui_domain_name='app.example.com', + environment_context={}, + read_attributes=None, + write_attributes=None, + callback_path='/auth/callback/staff/jcc', + ) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + { + 'CallbackURLs': [ + 'https://app.example.com/auth/callback/staff/jcc', + 'https://app.example.com/auth/callback', + ], + }, + ) + def test_add_default_app_client_domain_creates_cognito_domain(self): pool = _make_pool(self.stack) pool.add_default_app_client_domain('testprefix') diff --git a/backend/compact-connect/docs/internal/postman/postman-collection.json b/backend/compact-connect/docs/internal/postman/postman-collection.json index adfde0af90..0b1347cc9f 100644 --- a/backend/compact-connect/docs/internal/postman/postman-collection.json +++ b/backend/compact-connect/docs/internal/postman/postman-collection.json @@ -161,10 +161,10 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/jcc" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback/staff/jcc" } }, "response": [] @@ -237,14 +237,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/jcc" }, { "key": "scope", "value": "openid" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/jcc&scope=openid" } }, "response": [] @@ -309,14 +309,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/jcc" }, { "key": "identity_provider", "value": "COGNITO" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/jcc&identity_provider=COGNITO" } }, "response": [] diff --git a/backend/compact-connect/docs/postman/postman-collection.json b/backend/compact-connect/docs/postman/postman-collection.json index 90ab6b2f7e..a4650d21f5 100644 --- a/backend/compact-connect/docs/postman/postman-collection.json +++ b/backend/compact-connect/docs/postman/postman-collection.json @@ -161,10 +161,10 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/jcc" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback/staff/jcc" } }, "response": [] @@ -237,14 +237,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/jcc" }, { "key": "scope", "value": "openid" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/jcc&scope=openid" } }, "response": [] @@ -309,14 +309,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/jcc" }, { "key": "identity_provider", "value": "COGNITO" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/jcc&identity_provider=COGNITO" } }, "response": [] diff --git a/backend/compact-connect/stacks/persistent_stack/staff_users.py b/backend/compact-connect/stacks/persistent_stack/staff_users.py index b35f173a46..c1e8acf41b 100644 --- a/backend/compact-connect/stacks/persistent_stack/staff_users.py +++ b/backend/compact-connect/stacks/persistent_stack/staff_users.py @@ -92,6 +92,7 @@ def __init__( self.ui_client = self.add_ui_client( ui_domain_name=stack.ui_domain_name, environment_context=environment_context, + callback_path='/auth/callback/staff/jcc', # We have to provide one True value or CFn will make every attribute writeable write_attributes=ClientAttributes().with_standard_attributes(email=True), # We want to limit the attributes that this app can read and write so only email is visible. diff --git a/backend/compact-connect/stacks/provider_users/provider_users.py b/backend/compact-connect/stacks/provider_users/provider_users.py index 576b73427d..e4a9525bd3 100644 --- a/backend/compact-connect/stacks/provider_users/provider_users.py +++ b/backend/compact-connect/stacks/provider_users/provider_users.py @@ -81,6 +81,7 @@ def __init__( self.ui_client = self.add_ui_client( ui_domain_name=persistent_stack.ui_domain_name, environment_context=environment_context, + callback_path='/auth/callback/licensee/jcc', # For now, we are allowing the user to read and update their email. # we only allow the user to be able to see their providerId and compact, which are custom attributes. # If we ever want other attributes to be read or written, they must be added here. diff --git a/backend/compact-connect/tests/app/base.py b/backend/compact-connect/tests/app/base.py index 5fe1a8e81c..13d4e7661b 100644 --- a/backend/compact-connect/tests/app/base.py +++ b/backend/compact-connect/tests/app/base.py @@ -122,10 +122,12 @@ def _inspect_provider_users_stack( provider_users_stack_template = Template.from_stack(provider_users_stack) callbacks = [] if domain_name is not None: + callbacks.append(f'https://{domain_name}/auth/callback/licensee/jcc') callbacks.append(f'https://{domain_name}/auth/callback') if allow_local_ui: # 3018 is default local_ui_port = '3018' if not local_ui_port else local_ui_port + callbacks.append(f'http://localhost:{local_ui_port}/auth/callback/licensee/jcc') callbacks.append(f'http://localhost:{local_ui_port}/auth/callback') # Ensure our provider user pool is created with expected custom attributes @@ -209,10 +211,12 @@ def _inspect_persistent_stack( callbacks = [] if domain_name is not None: + callbacks.append(f'https://{domain_name}/auth/callback/staff/jcc') callbacks.append(f'https://{domain_name}/auth/callback') if allow_local_ui: # 3018 is default local_ui_port = '3018' if not local_ui_port else local_ui_port + callbacks.append(f'http://localhost:{local_ui_port}/auth/callback/staff/jcc') callbacks.append(f'http://localhost:{local_ui_port}/auth/callback') # ensure we have one user pool defined in persistent stack for staff users (provider user pool defined in diff --git a/backend/cosmetology-app/docs/internal/postman/postman-collection.json b/backend/cosmetology-app/docs/internal/postman/postman-collection.json index 8c9b76ac6d..50650922d9 100644 --- a/backend/cosmetology-app/docs/internal/postman/postman-collection.json +++ b/backend/cosmetology-app/docs/internal/postman/postman-collection.json @@ -161,10 +161,10 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo" } }, "response": [] @@ -237,14 +237,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" }, { "key": "scope", "value": "openid" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo&scope=openid" } }, "response": [] @@ -309,14 +309,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" }, { "key": "identity_provider", "value": "COGNITO" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo&identity_provider=COGNITO" } }, "response": [] diff --git a/backend/cosmetology-app/docs/postman/postman-collection.json b/backend/cosmetology-app/docs/postman/postman-collection.json index c6328c0c6c..ae853cd272 100644 --- a/backend/cosmetology-app/docs/postman/postman-collection.json +++ b/backend/cosmetology-app/docs/postman/postman-collection.json @@ -161,10 +161,10 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo" } }, "response": [] @@ -237,14 +237,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" }, { "key": "scope", "value": "openid" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo&scope=openid" } }, "response": [] @@ -309,14 +309,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" }, { "key": "identity_provider", "value": "COGNITO" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo&identity_provider=COGNITO" } }, "response": [] diff --git a/backend/cosmetology-app/docs/search-internal/postman/postman-collection.json b/backend/cosmetology-app/docs/search-internal/postman/postman-collection.json index 9975537a22..8acf2017f5 100644 --- a/backend/cosmetology-app/docs/search-internal/postman/postman-collection.json +++ b/backend/cosmetology-app/docs/search-internal/postman/postman-collection.json @@ -161,10 +161,10 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo" } }, "response": [] @@ -237,14 +237,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" }, { "key": "scope", "value": "openid" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo&scope=openid" } }, "response": [] @@ -309,14 +309,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/cosmo" }, { "key": "identity_provider", "value": "COGNITO" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/cosmo&identity_provider=COGNITO" } }, "response": [] diff --git a/backend/cosmetology-app/stacks/persistent_stack/staff_users.py b/backend/cosmetology-app/stacks/persistent_stack/staff_users.py index 5cc75508ac..d9272301c4 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/staff_users.py +++ b/backend/cosmetology-app/stacks/persistent_stack/staff_users.py @@ -92,6 +92,7 @@ def __init__( self.ui_client = self.add_ui_client( ui_domain_name=stack.ui_domain_name, environment_context=environment_context, + callback_path='/auth/callback/staff/cosmo', # We have to provide one True value or CFn will make every attribute writeable write_attributes=ClientAttributes().with_standard_attributes(email=True), # We want to limit the attributes that this app can read and write so only email is visible. diff --git a/backend/cosmetology-app/tests/app/base.py b/backend/cosmetology-app/tests/app/base.py index ab2dc81771..e9e48b43c6 100644 --- a/backend/cosmetology-app/tests/app/base.py +++ b/backend/cosmetology-app/tests/app/base.py @@ -158,10 +158,12 @@ def _inspect_persistent_stack( callbacks = [] if ui_domain_name is not None: + callbacks.append(f'https://{ui_domain_name}/auth/callback/staff/cosmo') callbacks.append(f'https://{ui_domain_name}/auth/callback') if allow_local_ui: # 3018 is default local_ui_port = '3018' if not local_ui_port else local_ui_port + callbacks.append(f'http://localhost:{local_ui_port}/auth/callback/staff/cosmo') callbacks.append(f'http://localhost:{local_ui_port}/auth/callback') # ensure we have one user pool defined in persistent stack for staff users diff --git a/backend/social-work-app/docs/internal/postman/postman-collection.json b/backend/social-work-app/docs/internal/postman/postman-collection.json index 72b5b4a0f3..dd107f4165 100644 --- a/backend/social-work-app/docs/internal/postman/postman-collection.json +++ b/backend/social-work-app/docs/internal/postman/postman-collection.json @@ -161,10 +161,10 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork" } }, "response": [] @@ -237,14 +237,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" }, { "key": "scope", "value": "openid" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork&scope=openid" } }, "response": [] @@ -309,14 +309,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" }, { "key": "identity_provider", "value": "COGNITO" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork&identity_provider=COGNITO" } }, "response": [] diff --git a/backend/social-work-app/docs/postman/postman-collection.json b/backend/social-work-app/docs/postman/postman-collection.json index a9e6d8873a..36992e3035 100644 --- a/backend/social-work-app/docs/postman/postman-collection.json +++ b/backend/social-work-app/docs/postman/postman-collection.json @@ -161,10 +161,10 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork" } }, "response": [] @@ -237,14 +237,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" }, { "key": "scope", "value": "openid" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork&scope=openid" } }, "response": [] @@ -309,14 +309,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" }, { "key": "identity_provider", "value": "COGNITO" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork&identity_provider=COGNITO" } }, "response": [] diff --git a/backend/social-work-app/docs/search-internal/postman/postman-collection.json b/backend/social-work-app/docs/search-internal/postman/postman-collection.json index e144e2ba69..fe26182830 100644 --- a/backend/social-work-app/docs/search-internal/postman/postman-collection.json +++ b/backend/social-work-app/docs/search-internal/postman/postman-collection.json @@ -161,10 +161,10 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork" } }, "response": [] @@ -237,14 +237,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" }, { "key": "scope", "value": "openid" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork&scope=openid" } }, "response": [] @@ -309,14 +309,14 @@ }, { "key": "redirect_uri", - "value": "http://localhost:3018/auth/callback" + "value": "http://localhost:3018/auth/callback/staff/socialwork" }, { "key": "identity_provider", "value": "COGNITO" } ], - "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback/staff/socialwork&identity_provider=COGNITO" } }, "response": [] diff --git a/backend/social-work-app/stacks/persistent_stack/staff_users.py b/backend/social-work-app/stacks/persistent_stack/staff_users.py index 7769c11c92..324754a522 100644 --- a/backend/social-work-app/stacks/persistent_stack/staff_users.py +++ b/backend/social-work-app/stacks/persistent_stack/staff_users.py @@ -92,6 +92,7 @@ def __init__( self.ui_client = self.add_ui_client( ui_domain_name=stack.ui_domain_name, environment_context=environment_context, + callback_path='/auth/callback/staff/socialwork', # We have to provide one True value or CFn will make every attribute writeable write_attributes=ClientAttributes().with_standard_attributes(email=True), # We want to limit the attributes that this app can read and write so only email is visible. diff --git a/backend/social-work-app/tests/app/base.py b/backend/social-work-app/tests/app/base.py index 77717fcb9d..5cf8248507 100644 --- a/backend/social-work-app/tests/app/base.py +++ b/backend/social-work-app/tests/app/base.py @@ -158,10 +158,12 @@ def _inspect_persistent_stack( callbacks = [] if ui_domain_name is not None: + callbacks.append(f'https://{ui_domain_name}/auth/callback/staff/socialwork') callbacks.append(f'https://{ui_domain_name}/auth/callback') if allow_local_ui: # 3018 is default local_ui_port = '3018' if not local_ui_port else local_ui_port + callbacks.append(f'http://localhost:{local_ui_port}/auth/callback/staff/socialwork') callbacks.append(f'http://localhost:{local_ui_port}/auth/callback') # ensure we have one user pool defined in persistent stack for staff users diff --git a/webroot/src/app.config.ts b/webroot/src/app.config.ts index 0b9ad5bb71..fa69000e0e 100644 --- a/webroot/src/app.config.ts +++ b/webroot/src/app.config.ts @@ -4,9 +4,6 @@ // // Created by InspiringApps on 4/27/21. // -import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; -import localStorage from '@store/local.storage'; -import moment from 'moment'; // =============== // = App Modes = @@ -15,8 +12,6 @@ export enum AppModes { JCC = 'jcc', COSMETOLOGY = 'cosmo', SOCIAL_WORK = 'social-work', - PRIVILEGE_PURCHASE = 'privilege-purchase', - MULTI_STATE = 'multi-state', } export enum AppGroupModes { @@ -24,91 +19,6 @@ export enum AppGroupModes { MULTI_STATE = 'multi-state', } -// ========================= -// = Authorization Types = -// ========================= -export enum AuthTypes { - STAFF = 'staff', - LICENSEE = 'licensee', - PUBLIC = 'public', -} - -export enum CognitoStateTypes { - STAFF_JCC = 'staff', - STAFF_COSMETOLOGY = 'staff-cosmo', - STAFF_SOCIAL_WORK = 'staff-social-work', - LICENSEE_JCC = 'licensee', -} - -export const staffLoginScopes = 'email openid phone profile aws.cognito.signin.user.admin'; -export const licenseeLoginScopes = 'email openid phone profile aws.cognito.signin.user.admin'; - -export type CognitoConfig = { - scopes?: string; - clientId?: string; - authDomain?: string; - state?: string; -}; -export const getCognitoConfig = (appMode: AppModes, authType: AuthTypes): CognitoConfig => { - const config: CognitoConfig = { - scopes: '', - clientId: '', - authDomain: '', - state: '', - }; - - switch (authType) { - case AuthTypes.STAFF: - config.scopes = staffLoginScopes; - - if (appMode === AppModes.JCC) { - config.state = CognitoStateTypes.STAFF_JCC; - config.clientId = envConfig.cognitoClientIdStaff; - config.authDomain = envConfig.cognitoAuthDomainStaff; - } else if (appMode === AppModes.COSMETOLOGY) { - config.state = CognitoStateTypes.STAFF_COSMETOLOGY; - config.clientId = envConfig.cognitoClientIdStaffCosmo; - config.authDomain = envConfig.cognitoAuthDomainStaffCosmo; - } else if (appMode === AppModes.SOCIAL_WORK) { - config.state = CognitoStateTypes.STAFF_SOCIAL_WORK; - config.clientId = envConfig.cognitoClientIdStaffSw; - config.authDomain = envConfig.cognitoAuthDomainStaffSw; - } - - break; - case AuthTypes.LICENSEE: - config.scopes = licenseeLoginScopes; - config.state = CognitoStateTypes.LICENSEE_JCC; - config.clientId = envConfig.cognitoClientIdLicensee; - config.authDomain = envConfig.cognitoAuthDomainLicensee; - break; - default: - break; - } - - return config; -}; - -export const getHostedLoginUri = (appMode: AppModes, authType: AuthTypes, hostedIdpPath = '/login'): string => { - const { domain } = envConfig; - const { - scopes, - clientId, - authDomain, - state - } = getCognitoConfig(appMode, authType); - const loginUriQuery = [ - `?client_id=${clientId}`, - `&response_type=code`, - `&scope=${encodeURIComponent(scopes || '')}`, - `&state=${state}`, - `&redirect_uri=${encodeURIComponent(`${domain}/auth/callback`)}`, - ].join(''); - const loginUri = `${authDomain}${hostedIdpPath}${loginUriQuery}`; - - return loginUri; -}; - // ========================= // = Permission Types = // ========================= @@ -128,48 +38,6 @@ export enum FeeTypes { FLAT_FEE_PER_PRIVILEGE = 'FLAT_FEE_PER_PRIVILEGE' } -// ==================== -// = Auth storage = -// ==================== -export const authStorage = localStorage; -export const tokens = { - staff: { - AUTH_TOKEN: 'auth_token_staff', - AUTH_TOKEN_TYPE: 'auth_token_type_staff', - AUTH_TOKEN_EXPIRY: 'auth_token_expiry_staff', - ID_TOKEN: 'id_token_staff', - REFRESH_TOKEN: 'refresh_token_staff', - }, - licensee: { - AUTH_TOKEN: 'auth_token_licensee', - AUTH_TOKEN_TYPE: 'auth_token_type_licensee', - AUTH_TOKEN_EXPIRY: 'auth_token_expiry_licensee', - ID_TOKEN: 'id_token_licensee', - REFRESH_TOKEN: 'refresh_token_licensee', - }, -}; -export const AUTH_TYPE = 'auth_type'; -export const AUTH_LOGIN_GOTO_PATH = 'login_goto'; -export const AUTH_LOGIN_GOTO_PATH_AUTH_TYPE = 'login_goto_auth_type'; -export const AUTH_LOGIN_GOTO_COMPACT = 'login_goto_compact'; - -// ==================== -// = Auto logout = -// ==================== -export const autoLogoutConfig = { - INACTIVITY_TIMER_DEFAULT_MS: moment.duration(10, 'minutes').asMilliseconds(), - INACTIVITY_TIMER_STAFF_MS: moment.duration(10, 'minutes').asMilliseconds(), - INACTIVITY_TIMER_LICENSEE_MS: moment.duration(10, 'minutes').asMilliseconds(), - GRACE_PERIOD_MS: moment.duration(30, 'seconds').asMilliseconds(), - LOG: (message = '') => { - const isEnabled = false; // Helper logging for auto-logout testing - - if (isEnabled) { - console.log(`auto-logout: ${message}`); - } - }, -}; - // ==================== // = User Languages = // ==================== @@ -356,10 +224,10 @@ export enum FeatureGates { } export default { - authStorage, - tokens, - AUTH_LOGIN_GOTO_PATH, - AUTH_LOGIN_GOTO_COMPACT, + AppModes, + AppGroupModes, + Permission, + FeeTypes, languagesEnabled, defaultLanguage, serverDateFormat, diff --git a/webroot/src/components/App/App.spec.ts b/webroot/src/components/App/App.spec.ts index be7cbec1ac..08c254aff6 100644 --- a/webroot/src/components/App/App.spec.ts +++ b/webroot/src/components/App/App.spec.ts @@ -5,7 +5,7 @@ // Created by InspiringApps on 4/12/20. // -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import { expect } from 'chai'; import { mountShallow } from '@tests/helpers/setup'; import App from '@components/App/App.vue'; diff --git a/webroot/src/components/App/App.ts b/webroot/src/components/App/App.ts index 05b9639478..6589508fb2 100644 --- a/webroot/src/components/App/App.ts +++ b/webroot/src/components/App/App.ts @@ -12,14 +12,13 @@ import { toNative } from 'vue-facing-decorator'; import { RouteRecordName } from 'vue-router'; +import { AppModes, relativeTimeFormats } from '@/app.config'; import { authStorage, - AppModes, AuthTypes, - relativeTimeFormats, AUTH_TYPE, AUTH_LOGIN_GOTO_COMPACT -} from '@/app.config'; +} from '@utils/auth'; import { CompactType } from '@models/Compact/Compact.model'; import PageContainer from '@components/Page/PageContainer/PageContainer.vue'; import Modal from '@components/Modal/Modal.vue'; diff --git a/webroot/src/components/AutoLogout/AutoLogout.ts b/webroot/src/components/AutoLogout/AutoLogout.ts index a2fa214812..059baf82a8 100644 --- a/webroot/src/components/AutoLogout/AutoLogout.ts +++ b/webroot/src/components/AutoLogout/AutoLogout.ts @@ -12,7 +12,7 @@ import { toNative } from 'vue-facing-decorator'; import { reactive, nextTick } from 'vue'; -import { autoLogoutConfig } from '@/app.config'; +import { autoLogoutConfig } from '@utils/auth'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import Modal from '@components/Modal/Modal.vue'; import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; diff --git a/webroot/src/components/ChangePassword/ChangePassword.ts b/webroot/src/components/ChangePassword/ChangePassword.ts index b71f188c69..04ed2c6d79 100644 --- a/webroot/src/components/ChangePassword/ChangePassword.ts +++ b/webroot/src/components/ChangePassword/ChangePassword.ts @@ -7,7 +7,7 @@ import { Component, mixins, toNative } from 'vue-facing-decorator'; import { reactive, computed } from 'vue'; -import { authStorage, AuthTypes, tokens } from '@/app.config'; +import { authStorage, AuthTypes, tokens } from '@utils/auth'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import InputPassword from '@components/Forms/InputPassword/InputPassword.vue'; import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; diff --git a/webroot/src/components/MilitaryAffiliationInfoBlock/MilitaryAffiliationInfoBlock.ts b/webroot/src/components/MilitaryAffiliationInfoBlock/MilitaryAffiliationInfoBlock.ts index ddaa8f9777..96a7fd6a55 100644 --- a/webroot/src/components/MilitaryAffiliationInfoBlock/MilitaryAffiliationInfoBlock.ts +++ b/webroot/src/components/MilitaryAffiliationInfoBlock/MilitaryAffiliationInfoBlock.ts @@ -12,7 +12,8 @@ import { mixins } from 'vue-facing-decorator'; import { reactive, computed } from 'vue'; -import { AuthTypes, MilitaryAuditStatusTypes } from '@/app.config'; +import { MilitaryAuditStatusTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import ListContainer from '@components/Lists/ListContainer/ListContainer.vue'; import InputButton from '@components/Forms/InputButton/InputButton.vue'; diff --git a/webroot/src/components/Page/PageMainNav/PageMainNav.ts b/webroot/src/components/Page/PageMainNav/PageMainNav.ts index 7a27eaca7a..ff8aa642a7 100644 --- a/webroot/src/components/Page/PageMainNav/PageMainNav.ts +++ b/webroot/src/components/Page/PageMainNav/PageMainNav.ts @@ -13,7 +13,7 @@ import { Raw } from 'vue'; import { Component, Vue, toNative } from 'vue-facing-decorator'; -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import RegisterIcon from '@components/Icons/Register/Register.vue'; import UploadIcon from '@components/Icons/Upload/Upload.vue'; import UsersIcon from '@components/Icons/Users/Users.vue'; diff --git a/webroot/src/components/StateSettingsList/StateSettingsList.ts b/webroot/src/components/StateSettingsList/StateSettingsList.ts index caca884621..0bec30bdea 100644 --- a/webroot/src/components/StateSettingsList/StateSettingsList.ts +++ b/webroot/src/components/StateSettingsList/StateSettingsList.ts @@ -12,7 +12,7 @@ import { toNative } from 'vue-facing-decorator'; import { reactive, nextTick } from 'vue'; -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import InputButton from '@components/Forms/InputButton/InputButton.vue'; import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; diff --git a/webroot/src/components/UserAccount/UserAccount.ts b/webroot/src/components/UserAccount/UserAccount.ts index 1fd8db0e8b..01c64d6bc6 100644 --- a/webroot/src/components/UserAccount/UserAccount.ts +++ b/webroot/src/components/UserAccount/UserAccount.ts @@ -12,7 +12,7 @@ import { toNative } from 'vue-facing-decorator'; import { reactive, computed, nextTick } from 'vue'; -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import InputButton from '@components/Forms/InputButton/InputButton.vue'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import Card from '@components/Card/Card.vue'; diff --git a/webroot/src/models/LicenseeUser/LicenseeUser.model.spec.ts b/webroot/src/models/LicenseeUser/LicenseeUser.model.spec.ts index bcbb1f34cf..0ce3f99245 100644 --- a/webroot/src/models/LicenseeUser/LicenseeUser.model.spec.ts +++ b/webroot/src/models/LicenseeUser/LicenseeUser.model.spec.ts @@ -4,7 +4,7 @@ // // Created by InspiringApps on 4/12/2020. // -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import { LicenseeUser, LicenseeUserSerializer, diff --git a/webroot/src/models/LicenseeUser/LicenseeUser.model.ts b/webroot/src/models/LicenseeUser/LicenseeUser.model.ts index 8b07eb6a2d..0de3a89e4d 100644 --- a/webroot/src/models/LicenseeUser/LicenseeUser.model.ts +++ b/webroot/src/models/LicenseeUser/LicenseeUser.model.ts @@ -6,7 +6,7 @@ // /* eslint-disable max-classes-per-file */ -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import { deleteUndefinedProperties } from '@models/_helpers'; import { Licensee, LicenseeSerializer } from '@models/Licensee/Licensee.model'; import { User, InterfaceUserCreate } from '@models/User/User.model'; diff --git a/webroot/src/models/StaffUser/StaffUser.model.spec.ts b/webroot/src/models/StaffUser/StaffUser.model.spec.ts index aeaee08b3f..a1cd5d7b95 100644 --- a/webroot/src/models/StaffUser/StaffUser.model.spec.ts +++ b/webroot/src/models/StaffUser/StaffUser.model.spec.ts @@ -4,7 +4,8 @@ // // Created by InspiringApps on 4/12/2020. // -import { AuthTypes, Permission } from '@/app.config'; +import { Permission } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import { StaffUser, StaffUserSerializer } from '@models/StaffUser/StaffUser.model'; import { Compact, CompactType } from '@models/Compact/Compact.model'; import { State } from '@models/State/State.model'; diff --git a/webroot/src/models/StaffUser/StaffUser.model.ts b/webroot/src/models/StaffUser/StaffUser.model.ts index 73020587c7..0f337e0be7 100644 --- a/webroot/src/models/StaffUser/StaffUser.model.ts +++ b/webroot/src/models/StaffUser/StaffUser.model.ts @@ -8,7 +8,8 @@ /* eslint-disable max-classes-per-file */ import { deleteUndefinedProperties } from '@models/_helpers'; -import { AuthTypes, Permission } from '@/app.config'; +import { Permission } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import { Compact, CompactType, CompactSerializer } from '@models/Compact/Compact.model'; import { State } from '@models/State/State.model'; import { User, InterfaceUserCreate } from '@models/User/User.model'; diff --git a/webroot/src/models/User/User.model.ts b/webroot/src/models/User/User.model.ts index 10a91ff05f..a5f7699e56 100644 --- a/webroot/src/models/User/User.model.ts +++ b/webroot/src/models/User/User.model.ts @@ -6,7 +6,7 @@ // /* eslint-disable max-classes-per-file */ -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import { deleteUndefinedProperties } from '@models/_helpers'; import { StatsigClient } from '@statsig/js-client'; diff --git a/webroot/src/network/licenseApi/data.api.ts b/webroot/src/network/licenseApi/data.api.ts index 4ab6d7127f..3adf7bbe6a 100644 --- a/webroot/src/network/licenseApi/data.api.ts +++ b/webroot/src/network/licenseApi/data.api.ts @@ -5,7 +5,7 @@ // Created by InspiringApps on 6/18/24. // -import { authStorage, tokens } from '@/app.config'; +import { authStorage, tokens } from '@utils/auth'; import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; import { requestError, diff --git a/webroot/src/network/licenseApi/interceptors.ts b/webroot/src/network/licenseApi/interceptors.ts index 65eb090d0b..633ec5fd84 100644 --- a/webroot/src/network/licenseApi/interceptors.ts +++ b/webroot/src/network/licenseApi/interceptors.ts @@ -4,7 +4,8 @@ // // Created by InspiringApps on 6/18/24. // -import { AppModes, authStorage, tokens } from '@/app.config'; +import { AppModes } from '@/app.config'; +import { authStorage, tokens } from '@utils/auth'; import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; // ============================================================================ diff --git a/webroot/src/network/searchApi/interceptors.ts b/webroot/src/network/searchApi/interceptors.ts index c449b64121..65298c6284 100644 --- a/webroot/src/network/searchApi/interceptors.ts +++ b/webroot/src/network/searchApi/interceptors.ts @@ -4,7 +4,8 @@ // // Created by InspiringApps on 12/15/25. // -import { AppModes, authStorage, tokens } from '@/app.config'; +import { AppModes } from '@/app.config'; +import { authStorage, tokens } from '@utils/auth'; import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; // ============================================================================ diff --git a/webroot/src/network/stateApi/interceptors.ts b/webroot/src/network/stateApi/interceptors.ts index fb825856ff..cf4e07b1bd 100644 --- a/webroot/src/network/stateApi/interceptors.ts +++ b/webroot/src/network/stateApi/interceptors.ts @@ -4,7 +4,8 @@ // // Created by InspiringApps on 6/18/24. // -import { AppModes, authStorage, tokens } from '@/app.config'; +import { AppModes } from '@/app.config'; +import { authStorage, tokens } from '@utils/auth'; import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; // ============================================================================ diff --git a/webroot/src/network/userApi/interceptors.ts b/webroot/src/network/userApi/interceptors.ts index f98b1cc17d..933d804822 100644 --- a/webroot/src/network/userApi/interceptors.ts +++ b/webroot/src/network/userApi/interceptors.ts @@ -4,7 +4,8 @@ // // Created by InspiringApps on 6/18/24. // -import { AppModes, authStorage, tokens } from '@/app.config'; +import { AppModes } from '@/app.config'; +import { authStorage, tokens } from '@utils/auth'; import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; // ============================================================================ diff --git a/webroot/src/pages/AuthCallback/AuthCallback.spec.ts b/webroot/src/pages/AuthCallback/AuthCallback.spec.ts deleted file mode 100644 index b134ab4597..0000000000 --- a/webroot/src/pages/AuthCallback/AuthCallback.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -// -// AuthCallback.spec.ts -// CompactConnect -// -// Created by InspiringApps on 8/12/2024. -// - -import { expect } from 'chai'; -import { mountShallow } from '@tests/helpers/setup'; -import AuthCallback from '@pages/AuthCallback/AuthCallback.vue'; - -describe('AuthCallback page', async () => { - it('should mount the page component', async () => { - const wrapper = await mountShallow(AuthCallback); - - expect(wrapper.exists()).to.equal(true); - expect(wrapper.findComponent(AuthCallback).exists()).to.equal(true); - }); -}); diff --git a/webroot/src/pages/AuthCallback/AuthCallback.ts b/webroot/src/pages/AuthCallback/AuthCallback.ts deleted file mode 100644 index 8c1de9e4ef..0000000000 --- a/webroot/src/pages/AuthCallback/AuthCallback.ts +++ /dev/null @@ -1,218 +0,0 @@ -// -// AuthCallback.ts -// CompactConnect -// -// Created by InspiringApps on 8/12/2024. -// - -import { nextTick } from 'vue'; -import { Component, Vue } from 'vue-facing-decorator'; -import Section from '@components/Section/Section.vue'; -import Card from '@components/Card/Card.vue'; -import { - authStorage, - AppModes, - CognitoStateTypes, - AuthTypes, - AUTH_TYPE, - AUTH_LOGIN_GOTO_PATH, - AUTH_LOGIN_GOTO_PATH_AUTH_TYPE -} from '@/app.config'; -import axios from 'axios'; - -@Component({ - name: 'AuthCallback', - components: { - Section, - Card, - } -}) -export default class AuthCallback extends Vue { - // - // Data - // - isError = false; - - // - // Lifecycle - // - async created() { - await this.getTokens(); - } - - // - // Computed - // - get authorizationCode(): string { - return this.$route.query?.code?.toString() || ''; - } - - get userType(): string { - // The state query param is used by cognito to pass a value through the login workflow - // where we hop between domains. Here we are using it to keep track of which login - // screen we just returned from. Docs here: - // https://docs.aws.amazon.com/cognito/latest/developerguide/authorization-endpoint.html - - return this.$route.query?.state?.toString() || ''; - } - - // - // Methods - // - async getTokens(): Promise { - this.$store.dispatch('startLoading'); - const { userType } = this; - - if (userType === CognitoStateTypes.STAFF_JCC) { - this.$store.dispatch('setAppMode', AppModes.JCC); - await this.getTokensStaffJcc().catch(() => { - this.isError = true; - }); - } else if (userType === CognitoStateTypes.LICENSEE_JCC) { - this.$store.dispatch('setAppMode', AppModes.JCC); - await this.getTokensLicenseeJcc().catch(() => { - this.isError = true; - }); - } else if (userType === CognitoStateTypes.STAFF_COSMETOLOGY) { - this.$store.dispatch('setAppMode', AppModes.COSMETOLOGY); - await this.getTokensStaffCosmo().catch(() => { - this.isError = true; - }); - } else if (userType === CognitoStateTypes.STAFF_SOCIAL_WORK) { - this.$store.dispatch('setAppMode', AppModes.SOCIAL_WORK); - await this.getTokensStaffSw().catch(() => { - this.isError = true; - }); - } else { - // If the state query param is absent or not matching we will - // still try to get tokens, if the user just logged in one of the - // user pools will successfully return tokens. If none then we enter - // the error state. - - let errorCount = 0; - - await this.getTokensStaffJcc() - .then(() => { - this.$store.dispatch('setAppMode', AppModes.JCC); - }) - .catch(() => { - errorCount += 1; - }); - - if (errorCount > 0) { - await this.getTokensStaffCosmo() - .then(() => { - this.$store.dispatch('setAppMode', AppModes.COSMETOLOGY); - }) - .catch(() => { - errorCount += 1; - }); - } - - if (errorCount > 1) { - await this.getTokensStaffSw() - .then(() => { - this.$store.dispatch('setAppMode', AppModes.SOCIAL_WORK); - }) - .catch(() => { - errorCount += 1; - }); - } - - if (errorCount > 2) { - await this.getTokensLicenseeJcc() - .then(() => { - this.$store.dispatch('setAppMode', AppModes.JCC); - }).catch(() => { - errorCount += 1; - }); - } - - if (errorCount > 2) { - this.isError = true; - } - } - - this.$store.dispatch('endLoading'); - - if (!this.isError) { - await this.redirectUser(); - } - } - - async getTokensStaffJcc(): Promise { - const { domain, cognitoAuthDomainStaff, cognitoClientIdStaff } = this.$envConfig; - const params = new URLSearchParams(); - - params.append('grant_type', 'authorization_code'); - params.append('client_id', cognitoClientIdStaff || ''); - params.append('redirect_uri', `${domain}${this.$route.path}`); - params.append('code', this.authorizationCode); - - const { data } = await axios.post(`${cognitoAuthDomainStaff}/oauth2/token`, params); - - await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType: AuthTypes.STAFF }); - await this.$store.dispatch('user/loginSuccess', AuthTypes.STAFF); - } - - async getTokensStaffCosmo(): Promise { - const { domain, cognitoAuthDomainStaffCosmo, cognitoClientIdStaffCosmo } = this.$envConfig; - const params = new URLSearchParams(); - - params.append('grant_type', 'authorization_code'); - params.append('client_id', cognitoClientIdStaffCosmo || ''); - params.append('redirect_uri', `${domain}${this.$route.path}`); - params.append('code', this.authorizationCode); - - const { data } = await axios.post(`${cognitoAuthDomainStaffCosmo}/oauth2/token`, params); - - await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType: AuthTypes.STAFF }); - await this.$store.dispatch('user/loginSuccess', AuthTypes.STAFF); - } - - async getTokensStaffSw(): Promise { - const { domain, cognitoAuthDomainStaffSw, cognitoClientIdStaffSw } = this.$envConfig; - const params = new URLSearchParams(); - - params.append('grant_type', 'authorization_code'); - params.append('client_id', cognitoClientIdStaffSw || ''); - params.append('redirect_uri', `${domain}${this.$route.path}`); - params.append('code', this.authorizationCode); - - const { data } = await axios.post(`${cognitoAuthDomainStaffSw}/oauth2/token`, params); - - await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType: AuthTypes.STAFF }); - await this.$store.dispatch('user/loginSuccess', AuthTypes.STAFF); - } - - async getTokensLicenseeJcc(): Promise { - const { domain, cognitoAuthDomainLicensee, cognitoClientIdLicensee } = this.$envConfig; - const params = new URLSearchParams(); - - params.append('grant_type', 'authorization_code'); - params.append('client_id', cognitoClientIdLicensee || ''); - params.append('redirect_uri', `${domain}${this.$route.path}`); - params.append('code', this.authorizationCode); - - const { data } = await axios.post(`${cognitoAuthDomainLicensee}/oauth2/token`, params); - - await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType: AuthTypes.LICENSEE }); - await this.$store.dispatch('user/loginSuccess', AuthTypes.LICENSEE); - } - - async redirectUser(): Promise { - const goto = authStorage.getItem(AUTH_LOGIN_GOTO_PATH); - const gotoAuthType = authStorage.getItem(AUTH_LOGIN_GOTO_PATH_AUTH_TYPE); - const currentAuthType = authStorage.getItem(AUTH_TYPE); - - authStorage.removeItem(AUTH_LOGIN_GOTO_PATH); - authStorage.removeItem(AUTH_LOGIN_GOTO_PATH_AUTH_TYPE); - - if (goto && (!gotoAuthType || gotoAuthType === currentAuthType)) { - this.$router.push({ path: goto }); - } else { - await nextTick(); - this.$router.push({ name: 'Home' }); - } - } -} diff --git a/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.less b/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.less new file mode 100644 index 0000000000..ef44620b4b --- /dev/null +++ b/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.less @@ -0,0 +1,10 @@ +// +// LicenseeJcc.less +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +.auth-callback-container { + .auth-error(); +} diff --git a/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.spec.ts b/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.spec.ts new file mode 100644 index 0000000000..c8d7dcb8db --- /dev/null +++ b/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.spec.ts @@ -0,0 +1,19 @@ +// +// LicenseeJcc.spec.ts +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import LicenseeJcc from '@pages/AuthCallback/LicenseeJcc/LicenseeJcc.vue'; + +describe('LicenseeJcc page', async () => { + it('should mount the page component', async () => { + const wrapper = await mountShallow(LicenseeJcc); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(LicenseeJcc).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.ts b/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.ts new file mode 100644 index 0000000000..a427acb2ea --- /dev/null +++ b/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.ts @@ -0,0 +1,25 @@ +// +// LicenseeJcc.ts +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +import { AppModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import { Component, mixins } from 'vue-facing-decorator'; +import MixinAuthCallbackHandler from '@pages/AuthCallback/_mixins/handler.mixin'; + +@Component({ + name: 'AuthCallbackLicenseeJcc', +}) +export default class AuthCallbackLicenseeJcc extends mixins(MixinAuthCallbackHandler) { + // + // Data + // + appMode: AppModes = AppModes.JCC; + authType: AuthTypes = AuthTypes.LICENSEE; + cognitoAuthDomain = envConfig.cognitoAuthDomainLicensee || ''; + cognitoClientId = envConfig.cognitoClientIdLicensee || ''; +} diff --git a/webroot/src/pages/AuthCallback/AuthCallback.vue b/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.vue similarity index 76% rename from webroot/src/pages/AuthCallback/AuthCallback.vue rename to webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.vue index c2d3693a23..287d973e44 100644 --- a/webroot/src/pages/AuthCallback/AuthCallback.vue +++ b/webroot/src/pages/AuthCallback/LicenseeJcc/LicenseeJcc.vue @@ -1,8 +1,8 @@ - - + + diff --git a/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.less b/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.less new file mode 100644 index 0000000000..f4c141030a --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.less @@ -0,0 +1,10 @@ +// +// StaffCosmo.less +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +.auth-callback-container { + .auth-error(); +} diff --git a/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.spec.ts b/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.spec.ts new file mode 100644 index 0000000000..a30fdbfb46 --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.spec.ts @@ -0,0 +1,19 @@ +// +// StaffCosmo.spec.ts +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import StaffCosmo from '@pages/AuthCallback/StaffCosmo/StaffCosmo.vue'; + +describe('StaffCosmo page', async () => { + it('should mount the page component', async () => { + const wrapper = await mountShallow(StaffCosmo); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(StaffCosmo).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.ts b/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.ts new file mode 100644 index 0000000000..a7c2d49e0b --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.ts @@ -0,0 +1,25 @@ +// +// StaffCosmo.ts +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +import { AppModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import { Component, mixins } from 'vue-facing-decorator'; +import MixinAuthCallbackHandler from '@pages/AuthCallback/_mixins/handler.mixin'; + +@Component({ + name: 'AuthCallbackStaffCosmo', +}) +export default class AuthCallbackStaffCosmo extends mixins(MixinAuthCallbackHandler) { + // + // Data + // + appMode: AppModes = AppModes.COSMETOLOGY; + authType: AuthTypes = AuthTypes.STAFF; + cognitoAuthDomain = envConfig.cognitoAuthDomainStaffCosmo || ''; + cognitoClientId = envConfig.cognitoClientIdStaffCosmo || ''; +} diff --git a/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.vue b/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.vue new file mode 100644 index 0000000000..0498656920 --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffCosmo/StaffCosmo.vue @@ -0,0 +1,23 @@ + + + + + + diff --git a/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.less b/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.less new file mode 100644 index 0000000000..6b9fe9f2c0 --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.less @@ -0,0 +1,10 @@ +// +// StaffJcc.less +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +.auth-callback-container { + .auth-error(); +} diff --git a/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.spec.ts b/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.spec.ts new file mode 100644 index 0000000000..7ae80c6c31 --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.spec.ts @@ -0,0 +1,19 @@ +// +// StaffJcc.spec.ts +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import StaffJcc from '@pages/AuthCallback/StaffJcc/StaffJcc.vue'; + +describe('StaffJcc page', async () => { + it('should mount the page component', async () => { + const wrapper = await mountShallow(StaffJcc); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(StaffJcc).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.ts b/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.ts new file mode 100644 index 0000000000..7c8b354fed --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.ts @@ -0,0 +1,25 @@ +// +// StaffJcc.ts +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +import { AppModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import { Component, mixins } from 'vue-facing-decorator'; +import MixinAuthCallbackHandler from '@pages/AuthCallback/_mixins/handler.mixin'; + +@Component({ + name: 'AuthCallbackStaffJcc', +}) +export default class AuthCallbackStaffJcc extends mixins(MixinAuthCallbackHandler) { + // + // Data + // + appMode: AppModes = AppModes.JCC; + authType: AuthTypes = AuthTypes.STAFF; + cognitoAuthDomain = envConfig.cognitoAuthDomainStaff || ''; + cognitoClientId = envConfig.cognitoClientIdStaff || ''; +} diff --git a/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.vue b/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.vue new file mode 100644 index 0000000000..f3bfce13a9 --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffJcc/StaffJcc.vue @@ -0,0 +1,23 @@ + + + + + + diff --git a/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.less b/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.less new file mode 100644 index 0000000000..fde566e6d9 --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.less @@ -0,0 +1,10 @@ +// +// StaffSocialWork.less +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +.auth-callback-container { + .auth-error(); +} diff --git a/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.spec.ts b/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.spec.ts new file mode 100644 index 0000000000..6d5187c4a1 --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.spec.ts @@ -0,0 +1,19 @@ +// +// StaffSocialWork.spec.ts +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import StaffSocialWork from '@pages/AuthCallback/StaffSocialWork/StaffSocialWork.vue'; + +describe('StaffSocialWork page', async () => { + it('should mount the page component', async () => { + const wrapper = await mountShallow(StaffSocialWork); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(StaffSocialWork).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.ts b/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.ts new file mode 100644 index 0000000000..f1466cfc3a --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.ts @@ -0,0 +1,25 @@ +// +// StaffSocialWork.ts +// CompactConnect +// +// Created by InspiringApps on 6/24/2026. +// + +import { AppModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import { Component, mixins } from 'vue-facing-decorator'; +import MixinAuthCallbackHandler from '@pages/AuthCallback/_mixins/handler.mixin'; + +@Component({ + name: 'AuthCallbackStaffSocialWork', +}) +export default class AuthCallbackStaffSocialWork extends mixins(MixinAuthCallbackHandler) { + // + // Data + // + appMode: AppModes = AppModes.SOCIAL_WORK; + authType: AuthTypes = AuthTypes.STAFF; + cognitoAuthDomain = envConfig.cognitoAuthDomainStaffSw || ''; + cognitoClientId = envConfig.cognitoClientIdStaffSw || ''; +} diff --git a/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.vue b/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.vue new file mode 100644 index 0000000000..b75520a8d2 --- /dev/null +++ b/webroot/src/pages/AuthCallback/StaffSocialWork/StaffSocialWork.vue @@ -0,0 +1,23 @@ + + + + + + diff --git a/webroot/src/pages/AuthCallback/_mixins/handler.mixin.ts b/webroot/src/pages/AuthCallback/_mixins/handler.mixin.ts new file mode 100644 index 0000000000..53194491cf --- /dev/null +++ b/webroot/src/pages/AuthCallback/_mixins/handler.mixin.ts @@ -0,0 +1,140 @@ +// +// handler.mixin.ts +// InspiringApps modules +// +// Created by InspiringApps on 6/24/2026. +// + +import { AppModes } from '@/app.config'; +import { + authStorage, + AuthTypes, + AUTH_TYPE, + AUTH_LOGIN_GOTO_PATH, + AUTH_LOGIN_GOTO_PATH_AUTH_TYPE, + consumeAuthCsrfState, + consumePkceCodeVerifier +} from '@utils/auth'; +import { nextTick } from 'vue'; +import { Component, Vue } from 'vue-facing-decorator'; +import Section from '@components/Section/Section.vue'; +import Card from '@components/Card/Card.vue'; +import axios from 'axios'; + +@Component({ + name: 'MixinAuthCallbackHandler', + components: { + Section, + Card, + }, +}) +class MixinAuthCallbackHandler extends Vue { + // + // Data (defaults) + // + appMode: AppModes = AppModes.JCC; + authType: AuthTypes = AuthTypes.LICENSEE; + cognitoAuthDomain = ''; + cognitoClientId = ''; + isError = false; + + // + // Lifecycle + // + async created() { + const { + appMode, + authType, + cognitoAuthDomain, + cognitoClientId + } = this; + + // Verify the OAuth `state` param matches the CSRF token stored before redirecting to the hosted UI. + // If it doesn't match, abort before exchanging the authorization code (possible CSRF / forged callback). + if (!this.verifyCsrfState()) { + this.isError = true; + + return; + } + + await this.getTokens(appMode, authType, cognitoAuthDomain, cognitoClientId); + } + + // + // Computed + // + // https://docs.aws.amazon.com/cognito/latest/developerguide/authorization-endpoint.html + get authorizationCode(): string { + return this.$route.query?.code?.toString() || ''; + } + + get stateParam(): string { + return this.$route.query?.state?.toString() || ''; + } + + // + // Methods + // + verifyCsrfState(): boolean { + const storedState = consumeAuthCsrfState(); // Reads and removes the stored token (single-use) + + return Boolean(storedState) && storedState === this.stateParam; + } + + async getTokens(appMode: AppModes, authType: AuthTypes, cognitoAuthDomain, cognitoClientId): Promise { + this.$store.dispatch('startLoading'); + this.$store.dispatch('setAppMode', appMode); + + await this.fetchCognitoTokens(authType, cognitoAuthDomain, cognitoClientId).catch(() => { + this.isError = true; + }); + + this.$store.dispatch('endLoading'); + + if (!this.isError) { + await this.redirectUser(); + } + } + + async fetchCognitoTokens(authType: AuthTypes, cognitoAuthDomain, cognitoClientId): Promise { + const { domain } = this.$envConfig; + const params = new URLSearchParams(); + + if (authType && cognitoAuthDomain && cognitoClientId) { + params.append('grant_type', 'authorization_code'); + params.append('client_id', cognitoClientId || ''); + params.append('redirect_uri', `${domain}${this.$route.path}`); + params.append('code', this.authorizationCode); + params.append('code_verifier', consumePkceCodeVerifier() || ''); + + const { data } = await axios.post(`${cognitoAuthDomain}/oauth2/token`, params); + + await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType }); + await this.$store.dispatch('user/loginSuccess', authType); + } else { + throw new Error(`missing parameters for token fetch`); + } + } + + async redirectUser(): Promise { + const goto = authStorage.getItem(AUTH_LOGIN_GOTO_PATH); + const gotoAuthType = authStorage.getItem(AUTH_LOGIN_GOTO_PATH_AUTH_TYPE); + const currentAuthType = authStorage.getItem(AUTH_TYPE); + + authStorage.removeItem(AUTH_LOGIN_GOTO_PATH); + authStorage.removeItem(AUTH_LOGIN_GOTO_PATH_AUTH_TYPE); + + // If user had a previous path stored then redirect there + if (goto && (!gotoAuthType || gotoAuthType === currentAuthType)) { + this.$router.push({ path: goto }); + } else { + // Otherwise let the Home page determine the default page + await nextTick(); + this.$router.push({ name: 'Home' }); + } + } +} + +// export default toNative(MixinAuthCallbackHandler); + +export default MixinAuthCallbackHandler; diff --git a/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts b/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts new file mode 100644 index 0000000000..7c5742a57b --- /dev/null +++ b/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts @@ -0,0 +1,89 @@ +// +// mixins.spec.ts +// InspiringApps modules +// +// Created by InspiringApps on 6/24/2026. +// + +import { mountShallow } from '@tests/helpers/setup'; +import AuthCallbackHandlerMixin from '@pages/AuthCallback/_mixins/handler.mixin'; +import { AppModes } from '@/app.config'; +import { AuthTypes, AUTH_CSRF_STATE } from '@utils/auth'; +import sessionStorage from '@store/session.storage'; + +const chaiMatchPattern = require('chai-match-pattern'); +const chai = require('chai').use(chaiMatchPattern); + +const { expect } = chai; + +describe('AuthCallbackHandler mixin', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(AuthCallbackHandlerMixin); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(AuthCallbackHandlerMixin).exists()).to.equal(true); + }); + it('should successfully get default query param values', async () => { + const wrapper = await mountShallow(AuthCallbackHandlerMixin); + const component = wrapper.vm; + + expect(component.authorizationCode).to.equal(''); + expect(component.stateParam).to.equal(''); + }); + it('should successfully get custom query param values', async () => { + const wrapper = await mountShallow(AuthCallbackHandlerMixin); + const component = wrapper.vm; + + component.$route.query.code = 'abc'; + component.$route.query.state = 'def'; + + expect(component.authorizationCode).to.equal('abc'); + expect(component.stateParam).to.equal('def'); + }); + it('should successfully get tokens', async () => { + const wrapper = await mountShallow(AuthCallbackHandlerMixin); + const component = wrapper.vm; + + await component.getTokens(AppModes.JCC, AuthTypes.STAFF, 'http://localhost', 'abc'); + + // If the tokens flow is successful then it ends by redirecting the user with a replaced router history state + expect(component.$router.options.history.state.replaced).to.equal(true); + }); + it('should verify a matching csrf state param', async () => { + const wrapper = await mountShallow(AuthCallbackHandlerMixin); + const component = wrapper.vm; + + sessionStorage.setItem(AUTH_CSRF_STATE, 'csrf-token-123'); + component.$route.query.state = 'csrf-token-123'; + + expect(component.verifyCsrfState()).to.equal(true); + }); + it('should reject a mismatched csrf state param', async () => { + const wrapper = await mountShallow(AuthCallbackHandlerMixin); + const component = wrapper.vm; + + sessionStorage.setItem(AUTH_CSRF_STATE, 'csrf-token-123'); + component.$route.query.state = 'csrf-token-999'; + + expect(component.verifyCsrfState()).to.equal(false); + }); + it('should reject when no csrf state is stored', async () => { + const wrapper = await mountShallow(AuthCallbackHandlerMixin); + const component = wrapper.vm; + + sessionStorage.removeItem(AUTH_CSRF_STATE); + component.$route.query.state = 'csrf-token-123'; + + expect(component.verifyCsrfState()).to.equal(false); + }); + it('should consume (remove) the stored csrf state after verifying', async () => { + const wrapper = await mountShallow(AuthCallbackHandlerMixin); + const component = wrapper.vm; + + sessionStorage.setItem(AUTH_CSRF_STATE, 'csrf-token-123'); + component.$route.query.state = 'csrf-token-123'; + component.verifyCsrfState(); + + expect(sessionStorage.getItem(AUTH_CSRF_STATE)).to.equal(null); + }); +}); diff --git a/webroot/src/pages/CompactSettings/CompactSettings.ts b/webroot/src/pages/CompactSettings/CompactSettings.ts index 482e34ee59..b213861b1c 100644 --- a/webroot/src/pages/CompactSettings/CompactSettings.ts +++ b/webroot/src/pages/CompactSettings/CompactSettings.ts @@ -6,7 +6,8 @@ // import { Component, Vue, Watch } from 'vue-facing-decorator'; -import { AppGroupModes, AuthTypes } from '@/app.config'; +import { AppGroupModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import Section from '@components/Section/Section.vue'; import PaymentProcessorConfig from '@components/PaymentProcessorConfig/PaymentProcessorConfig.vue'; import CompactSettingsConfig from '@components/CompactSettingsConfig/CompactSettingsConfig.vue'; diff --git a/webroot/src/pages/Home/Home.ts b/webroot/src/pages/Home/Home.ts index aaa5509994..beab67d3af 100644 --- a/webroot/src/pages/Home/Home.ts +++ b/webroot/src/pages/Home/Home.ts @@ -12,7 +12,7 @@ import { toNative } from 'vue-facing-decorator'; import { Compact } from '@models/Compact/Compact.model'; -import { AuthTypes, authStorage, AUTH_TYPE } from '@/app.config'; +import { AuthTypes, authStorage, AUTH_TYPE } from '@utils/auth'; @Component({ name: 'HomePage', diff --git a/webroot/src/pages/Logout/Logout.ts b/webroot/src/pages/Logout/Logout.ts index 49d2deee40..1d6c7900cb 100644 --- a/webroot/src/pages/Logout/Logout.ts +++ b/webroot/src/pages/Logout/Logout.ts @@ -6,15 +6,15 @@ // import { Component, Vue } from 'vue-facing-decorator'; +import { AppModes } from '@/app.config'; import { authStorage, - AppModes, + tokens, AuthTypes, AUTH_TYPE, AUTH_LOGIN_GOTO_PATH, - AUTH_LOGIN_GOTO_PATH_AUTH_TYPE, - tokens -} from '@/app.config'; + AUTH_LOGIN_GOTO_PATH_AUTH_TYPE +} from '@utils/auth'; @Component({ name: 'Logout', diff --git a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts index 0f328a8412..0c187eb4b7 100644 --- a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts +++ b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts @@ -11,14 +11,16 @@ import { Watch, toNative } from 'vue-facing-decorator'; +import { AppModes } from '@/app.config'; import { authStorage, - AppModes, AuthTypes, getHostedLoginUri, + createAuthCsrfState, + createPkceChallenge, AUTH_LOGIN_GOTO_PATH, AUTH_LOGIN_GOTO_PATH_AUTH_TYPE -} from '@/app.config'; +} from '@utils/auth'; import Section from '@components/Section/Section.vue'; import Card from '@components/Card/Card.vue'; import LoadingSpinner from '@components/LoadingSpinner/LoadingSpinner.vue'; @@ -46,10 +48,17 @@ class MfaResetConfirmLicensee extends Vue { isLoading = true; isSuccess = false; serverMessage = ''; + csrfState = ''; + pkceChallenge = ''; // // Lifecycle // + async created(): Promise { + this.csrfState = createAuthCsrfState(); + this.pkceChallenge = await createPkceChallenge(); + } + mounted(): void { this.initRecaptcha(); } @@ -80,7 +89,7 @@ class MfaResetConfirmLicensee extends Vue { } get hostedLoginUriLicensee(): string { - return getHostedLoginUri(this.appMode, AuthTypes.LICENSEE, '/login'); + return getHostedLoginUri(this.appMode, AuthTypes.LICENSEE, '/login', this.csrfState, this.pkceChallenge); } get isUsingMockApi(): boolean { diff --git a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts index efbd42d0df..5a0038916c 100644 --- a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts +++ b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts @@ -20,10 +20,14 @@ import { import { stateList, dateFormatPatterns, - AppModes, - AuthTypes, - getHostedLoginUri + AppModes } from '@/app.config'; +import { + AuthTypes, + getHostedLoginUri, + createAuthCsrfState, + createPkceChallenge +} from '@utils/auth'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import Section from '@components/Section/Section.vue'; import Card from '@components/Card/Card.vue'; @@ -71,15 +75,19 @@ class MfaResetStartLicensee extends mixins(MixinForm) { // isFinalError = false; isConfirmationScreen = false; + csrfState = ''; + pkceChallenge = ''; // // Lifecycle // - created() { + async created(): Promise { + this.csrfState = createAuthCsrfState(); + this.pkceChallenge = await createPkceChallenge(); this.initFormInputs(); } - mounted() { + mounted(): void { this.initExtraFields(); this.initRecaptcha(); } @@ -178,7 +186,13 @@ class MfaResetStartLicensee extends mixins(MixinForm) { } get hostedForgotPasswordUriLicensee(): string { - return getHostedLoginUri(this.appMode, AuthTypes.LICENSEE, '/forgotPassword'); + return getHostedLoginUri( + this.appMode, + AuthTypes.LICENSEE, + '/forgotPassword', + this.csrfState, + this.pkceChallenge + ); } get isUsingMockApi(): boolean { diff --git a/webroot/src/pages/PrivilegeDetail/PrivilegeDetail.ts b/webroot/src/pages/PrivilegeDetail/PrivilegeDetail.ts index 3512ccca61..38b5bd407c 100644 --- a/webroot/src/pages/PrivilegeDetail/PrivilegeDetail.ts +++ b/webroot/src/pages/PrivilegeDetail/PrivilegeDetail.ts @@ -10,7 +10,7 @@ import { Vue, Watch } from 'vue-facing-decorator'; -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import LoadingSpinner from '@components/LoadingSpinner/LoadingSpinner.vue'; import InputButton from '@components/Forms/InputButton/InputButton.vue'; import PrivilegeDetailBlock from '@components/PrivilegeDetailBlock/PrivilegeDetailBlock.vue'; diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.spec.ts b/webroot/src/pages/PublicDashboard/PublicDashboard.spec.ts index f92760bf4c..cdee869215 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.spec.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.spec.ts @@ -7,13 +7,11 @@ import { mountShallow } from '@tests/helpers/setup'; import PublicDashboard from '@pages/PublicDashboard/PublicDashboard.vue'; -import { - AppModes, - AuthTypes, - getCognitoConfig, - getHostedLoginUri -} from '@/app.config'; +import { AppModes } from '@/app.config'; +import { AuthTypes, getCognitoConfig, getHostedLoginUri } from '@utils/auth'; import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import { nextTick } from 'vue'; +import { flushPromises } from '@vue/test-utils'; const chaiMatchPattern = require('chai-match-pattern'); const chai = require('chai').use(chaiMatchPattern); @@ -53,30 +51,77 @@ describe('PublicDashboard page', async () => { scopes: '', clientId: '', authDomain: '', - state: '', }); }); it('should use fallback idp path in app.config', async () => { expect(getHostedLoginUri(AuthTypes.LICENSEE)).to.contain('/login'); }); - it('should get correct hosted login uri config for staff', async () => { + it('should get correct hosted login uri config for staff (jcc)', async () => { const wrapper = await mountShallow(PublicDashboard); const component = wrapper.vm; + await nextTick(); + await flushPromises(); + + expect(component.csrfState).to.be.a('string').with.length.above(0); + expect(component.pkceChallenge).to.be.a('string').with.length.above(0); expect(component.hostedLoginUriStaff).to.contain('/login'); expect(component.hostedLoginUriStaff).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); - expect(component.hostedLoginUriStaff).to.contain('&state=staff'); + expect(component.hostedLoginUriStaff).to.contain(`&state=${component.csrfState}`); + expect(component.hostedLoginUriStaff).to.contain(`&code_challenge=${component.pkceChallenge}`); + expect(component.hostedLoginUriStaff).to.contain('&code_challenge_method=S256'); expect(component.hostedLoginUriStaff).to.contain('&response_type=code'); - expect(component.hostedLoginUriStaff).to.contain('%2Fauth%2Fcallback'); + expect(component.hostedLoginUriStaff).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fjcc'); + }); + it('should get correct hosted login uri config for staff (cosmetology)', async () => { + const wrapper = await mountShallow(PublicDashboard); + const component = wrapper.vm; + + await nextTick(); + await flushPromises(); + + expect(component.csrfState).to.be.a('string').with.length.above(0); + expect(component.pkceChallenge).to.be.a('string').with.length.above(0); + expect(component.hostedLoginUriStaffCosmo).to.contain('/login'); + expect(component.hostedLoginUriStaffCosmo).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); + expect(component.hostedLoginUriStaffCosmo).to.contain(`&state=${component.csrfState}`); + expect(component.hostedLoginUriStaffCosmo).to.contain(`&code_challenge=${component.pkceChallenge}`); + expect(component.hostedLoginUriStaffCosmo).to.contain('&code_challenge_method=S256'); + expect(component.hostedLoginUriStaffCosmo).to.contain('&response_type=code'); + expect(component.hostedLoginUriStaffCosmo).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fcosmo'); }); - it('should get correct hosted login uri config for licensee', async () => { + it('should get correct hosted login uri config for staff (social work)', async () => { const wrapper = await mountShallow(PublicDashboard); const component = wrapper.vm; + await nextTick(); + await flushPromises(); + + expect(component.csrfState).to.be.a('string').with.length.above(0); + expect(component.pkceChallenge).to.be.a('string').with.length.above(0); + expect(component.hostedLoginUriStaffSw).to.contain('/login'); + expect(component.hostedLoginUriStaffSw).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); + expect(component.hostedLoginUriStaffSw).to.contain(`&state=${component.csrfState}`); + expect(component.hostedLoginUriStaffSw).to.contain(`&code_challenge=${component.pkceChallenge}`); + expect(component.hostedLoginUriStaffSw).to.contain('&code_challenge_method=S256'); + expect(component.hostedLoginUriStaffSw).to.contain('&response_type=code'); + expect(component.hostedLoginUriStaffSw).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fsocialwork'); + }); + it('should get correct hosted login uri config for licensee (jcc)', async () => { + const wrapper = await mountShallow(PublicDashboard); + const component = wrapper.vm; + + await nextTick(); + await flushPromises(); + + expect(component.csrfState).to.be.a('string').with.length.above(0); + expect(component.pkceChallenge).to.be.a('string').with.length.above(0); expect(component.hostedLoginUriLicensee).to.contain('/login'); expect(component.hostedLoginUriLicensee).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); - expect(component.hostedLoginUriLicensee).to.contain('&state=licensee'); + expect(component.hostedLoginUriLicensee).to.contain(`&state=${component.csrfState}`); + expect(component.hostedLoginUriLicensee).to.contain(`&code_challenge=${component.pkceChallenge}`); + expect(component.hostedLoginUriLicensee).to.contain('&code_challenge_method=S256'); expect(component.hostedLoginUriLicensee).to.contain('&response_type=code'); - expect(component.hostedLoginUriLicensee).to.contain('%2Fauth%2Fcallback'); + expect(component.hostedLoginUriLicensee).to.contain('%2Fauth%2Fcallback%2Flicensee%2Fjcc'); }); }); diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.ts b/webroot/src/pages/PublicDashboard/PublicDashboard.ts index 741a7d14ac..28492155a0 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.ts @@ -6,15 +6,17 @@ // import { Component, Vue } from 'vue-facing-decorator'; +import { AppModes } from '@/app.config'; import { authStorage, - AppModes, AuthTypes, getHostedLoginUri, + createAuthCsrfState, + createPkceChallenge, AUTH_LOGIN_GOTO_PATH, AUTH_LOGIN_GOTO_PATH_AUTH_TYPE, AUTH_LOGIN_GOTO_COMPACT -} from '@/app.config'; +} from '@utils/auth'; import Card from '@components/Card/Card.vue'; import SearchIcon from '@components/Icons/Search/Search.vue'; import RegisterIcon from '@components/Icons/RegisterAlt/RegisterAlt.vue'; @@ -35,10 +37,19 @@ import { CompactType } from '@models/Compact/Compact.model'; } }) export default class DashboardPublic extends Vue { + // + // Data + // + csrfState = ''; + pkceChallenge = ''; + // // Lifecycle // - created(): void { + async created(): Promise { + this.csrfState = createAuthCsrfState(); + this.pkceChallenge = await createPkceChallenge(); + if (this.bypassQuery) { this.bypassRedirect(); } @@ -68,19 +79,43 @@ export default class DashboardPublic extends Vue { } get hostedLoginUriStaff(): string { - return getHostedLoginUri(AppModes.JCC, AuthTypes.STAFF, this.hostedLoginUriPath); + return getHostedLoginUri( + AppModes.JCC, + AuthTypes.STAFF, + this.hostedLoginUriPath, + this.csrfState, + this.pkceChallenge + ); } get hostedLoginUriStaffCosmo(): string { - return getHostedLoginUri(AppModes.COSMETOLOGY, AuthTypes.STAFF, this.hostedLoginUriPath); + return getHostedLoginUri( + AppModes.COSMETOLOGY, + AuthTypes.STAFF, + this.hostedLoginUriPath, + this.csrfState, + this.pkceChallenge + ); } get hostedLoginUriStaffSw(): string { - return getHostedLoginUri(AppModes.SOCIAL_WORK, AuthTypes.STAFF, this.hostedLoginUriPath); + return getHostedLoginUri( + AppModes.SOCIAL_WORK, + AuthTypes.STAFF, + this.hostedLoginUriPath, + this.csrfState, + this.pkceChallenge + ); } get hostedLoginUriLicensee(): string { - return getHostedLoginUri(AppModes.JCC, AuthTypes.LICENSEE, this.hostedLoginUriPath); + return getHostedLoginUri( + AppModes.JCC, + AuthTypes.LICENSEE, + this.hostedLoginUriPath, + this.csrfState, + this.pkceChallenge + ); } get compactTypes(): typeof CompactType { diff --git a/webroot/src/pages/StateSettings/StateSettings.ts b/webroot/src/pages/StateSettings/StateSettings.ts index e8e92238d6..b57d9250a2 100644 --- a/webroot/src/pages/StateSettings/StateSettings.ts +++ b/webroot/src/pages/StateSettings/StateSettings.ts @@ -6,7 +6,7 @@ // import { Component, Vue, Watch } from 'vue-facing-decorator'; -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import Section from '@components/Section/Section.vue'; import StateSettingsConfig from '@components/StateSettingsConfig/StateSettingsConfig.vue'; import InputButton from '@components/Forms/InputButton/InputButton.vue'; diff --git a/webroot/src/router/index.ts b/webroot/src/router/index.ts index 0347f762f0..617b7fce39 100644 --- a/webroot/src/router/index.ts +++ b/webroot/src/router/index.ts @@ -8,12 +8,8 @@ import { createRouter, createWebHistory, RouteLocationNormalized as Route } from 'vue-router'; import routes from '@router/routes'; import store from '@/store'; -import { - AppModes, - authStorage, - AUTH_TYPE, - AuthTypes -} from '@/app.config'; +import { AppModes } from '@/app.config'; +import { authStorage, AUTH_TYPE, AuthTypes } from '@utils/auth'; import { CompactType, CompactSerializer } from '@models/Compact/Compact.model'; const router = createRouter({ diff --git a/webroot/src/router/routes.ts b/webroot/src/router/routes.ts index f0a496e355..edcd5e88ae 100644 --- a/webroot/src/router/routes.ts +++ b/webroot/src/router/routes.ts @@ -64,9 +64,27 @@ const routes: Array = [ beforeEnter: guards.noAuthGuard, }, { - path: '/auth/callback', - name: 'AuthCallback', - component: () => import(/* webpackChunkName: "home" */ '@pages/AuthCallback/AuthCallback.vue'), + path: '/auth/callback/staff/jcc', + name: 'AuthCallbackStaffJcc', + component: () => import(/* webpackChunkName: "home" */ '@pages/AuthCallback/StaffJcc/StaffJcc.vue'), + meta: { skipTransition: true }, + }, + { + path: '/auth/callback/staff/cosmo', + name: 'AuthCallbackStaffCosmo', + component: () => import(/* webpackChunkName: "home" */ '@pages/AuthCallback/StaffCosmo/StaffCosmo.vue'), + meta: { skipTransition: true }, + }, + { + path: '/auth/callback/staff/socialwork', + name: 'AuthCallbackStaffSocialWork', + component: () => import(/* webpackChunkName: "home" */ '@pages/AuthCallback/StaffSocialWork/StaffSocialWork.vue'), + meta: { skipTransition: true }, + }, + { + path: '/auth/callback/licensee/jcc', + name: 'AuthCallbackLicenseeJcc', + component: () => import(/* webpackChunkName: "home" */ '@pages/AuthCallback/LicenseeJcc/LicenseeJcc.vue'), meta: { skipTransition: true }, }, { diff --git a/webroot/src/store/global/global.mutations.ts b/webroot/src/store/global/global.mutations.ts index 3710cadf0e..240f777e38 100644 --- a/webroot/src/store/global/global.mutations.ts +++ b/webroot/src/store/global/global.mutations.ts @@ -5,7 +5,8 @@ // Created by InspiringApps on 4/12/20. // -import { AuthTypes, AppModes, AppGroupModes } from '@/app.config'; +import { AppModes, AppGroupModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import { AppMessage } from '@/models/AppMessage/AppMessage.model'; import { State } from './global.state'; diff --git a/webroot/src/store/global/global.spec.ts b/webroot/src/store/global/global.spec.ts index 4c18b79d1f..e649858909 100644 --- a/webroot/src/store/global/global.spec.ts +++ b/webroot/src/store/global/global.spec.ts @@ -5,7 +5,8 @@ // Created by InspiringApps on 4/12/20. // -import { AuthTypes, AppModes, AppGroupModes } from '@/app.config'; +import { AppModes, AppGroupModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import mutations, { MutationTypes } from './global.mutations'; import actions from './global.actions'; diff --git a/webroot/src/store/global/global.state.ts b/webroot/src/store/global/global.state.ts index d7ca7839d0..99587e23ff 100644 --- a/webroot/src/store/global/global.state.ts +++ b/webroot/src/store/global/global.state.ts @@ -4,7 +4,8 @@ // // Created by InspiringApps on 4/12/20. // -import { AuthTypes, AppModes, AppGroupModes } from '@/app.config'; +import { AppModes, AppGroupModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; import { AppMessage } from '@/models/AppMessage/AppMessage.model'; export interface State { diff --git a/webroot/src/store/user/user.actions.ts b/webroot/src/store/user/user.actions.ts index 5d0c4c7510..98feb46937 100644 --- a/webroot/src/store/user/user.actions.ts +++ b/webroot/src/store/user/user.actions.ts @@ -7,14 +7,14 @@ import { dataApi } from '@network/data.api'; import { config } from '@plugins/EnvConfig/envConfig.plugin'; +import { AppModes } from '@/app.config'; import { authStorage, - AppModes, AuthTypes, tokens, AUTH_TYPE, autoLogoutConfig -} from '@/app.config'; +} from '@utils/auth'; import localStorage from '@store/local.storage'; import { Compact, CompactType } from '@models/Compact/Compact.model'; import { PurchaseFlowStep } from '@/models/PurchaseFlowStep/PurchaseFlowStep.model'; diff --git a/webroot/src/store/user/user.mutations.ts b/webroot/src/store/user/user.mutations.ts index 04111fc2ff..8ec3d5b7be 100644 --- a/webroot/src/store/user/user.mutations.ts +++ b/webroot/src/store/user/user.mutations.ts @@ -8,7 +8,7 @@ import { Compact } from '@models/Compact/Compact.model'; import { LicenseeUser } from '@/models/LicenseeUser/LicenseeUser.model'; import { StaffUser } from '@/models/StaffUser/StaffUser.model'; import { PurchaseFlowStep } from '@/models/PurchaseFlowStep/PurchaseFlowStep.model'; -import { AuthTypes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; export enum MutationTypes { LOGIN_REQUEST = '[User] Login Request', diff --git a/webroot/src/store/user/user.spec.ts b/webroot/src/store/user/user.spec.ts index 481c4ef9c1..21bd4c0b39 100644 --- a/webroot/src/store/user/user.spec.ts +++ b/webroot/src/store/user/user.spec.ts @@ -5,13 +5,8 @@ // Created by InspiringApps on 6/12/24. // -import { - authStorage, - tokens, - FeeTypes, - AppModes, - AuthTypes -} from '@/app.config'; +import { FeeTypes, AppModes } from '@/app.config'; +import { authStorage, tokens, AuthTypes } from '@utils/auth'; import chaiMatchPattern from 'chai-match-pattern'; import chai from 'chai'; import { Compact, CompactType } from '@models/Compact/Compact.model'; diff --git a/webroot/src/store/user/user.state.ts b/webroot/src/store/user/user.state.ts index b8d35a5860..553b6ecb11 100644 --- a/webroot/src/store/user/user.state.ts +++ b/webroot/src/store/user/user.state.ts @@ -13,7 +13,7 @@ import { tokens, AuthTypes, AUTH_TYPE -} from '@/app.config'; +} from '@utils/auth'; import { PurchaseFlowState } from '@/models/PurchaseFlowState/PurchaseFlowState.model'; export interface State { diff --git a/webroot/src/styles.common/_mixins.less b/webroot/src/styles.common/_mixins.less index 7818ade862..d942669d58 100644 --- a/webroot/src/styles.common/_mixins.less +++ b/webroot/src/styles.common/_mixins.less @@ -12,4 +12,5 @@ @import './mixins/element-focus'; @import './mixins/lazy-load'; @import './mixins/visually-hidden'; +@import './mixins/auth-error'; @import './mixins/purchase-flow-buttons'; diff --git a/webroot/src/pages/AuthCallback/AuthCallback.less b/webroot/src/styles.common/mixins/auth-error.less similarity index 75% rename from webroot/src/pages/AuthCallback/AuthCallback.less rename to webroot/src/styles.common/mixins/auth-error.less index f01df17189..82756b6aa8 100644 --- a/webroot/src/pages/AuthCallback/AuthCallback.less +++ b/webroot/src/styles.common/mixins/auth-error.less @@ -1,11 +1,11 @@ // -// AuthCallback.less -// CompactConnect +// auth-error.less +// InspiringApps modules // -// Created by InspiringApps on 8/12/2024. +// Created by InspiringApps on 6/24/26. // -.auth-callback-container { +.auth-error() { .auth-error-container { display: flex; flex-direction: column; diff --git a/webroot/src/utils/auth.ts b/webroot/src/utils/auth.ts new file mode 100644 index 0000000000..c87ee401e0 --- /dev/null +++ b/webroot/src/utils/auth.ts @@ -0,0 +1,256 @@ +// +// app.config.ts +// InspiringApps modules +// +// Created by InspiringApps on 4/27/21. +// +import { AppModes } from '@/app.config'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import sessionStorage from '@store/session.storage'; +import localStorage from '@store/local.storage'; +import { v4 as uuidv4 } from 'uuid'; +import moment from 'moment'; + +// ==================== +// = Auth storage = +// ==================== +export const authStorage = localStorage; +export const tokens = { + staff: { + AUTH_TOKEN: 'auth_token_staff', + AUTH_TOKEN_TYPE: 'auth_token_type_staff', + AUTH_TOKEN_EXPIRY: 'auth_token_expiry_staff', + ID_TOKEN: 'id_token_staff', + REFRESH_TOKEN: 'refresh_token_staff', + }, + licensee: { + AUTH_TOKEN: 'auth_token_licensee', + AUTH_TOKEN_TYPE: 'auth_token_type_licensee', + AUTH_TOKEN_EXPIRY: 'auth_token_expiry_licensee', + ID_TOKEN: 'id_token_licensee', + REFRESH_TOKEN: 'refresh_token_licensee', + }, +}; +export const AUTH_TYPE = 'auth_type'; +export const AUTH_LOGIN_GOTO_PATH = 'login_goto'; +export const AUTH_LOGIN_GOTO_PATH_AUTH_TYPE = 'login_goto_auth_type'; +export const AUTH_LOGIN_GOTO_COMPACT = 'login_goto_compact'; +export const AUTH_CSRF_STATE = 'auth_csrf_state'; +export const AUTH_PKCE_CODE_VERIFIER = 'auth_pkce_code_verifier'; + +// ========================= +// = Authorization Types = +// ========================= +export enum AuthTypes { + STAFF = 'staff', + LICENSEE = 'licensee', + PUBLIC = 'public', +} + +// =========================== +// = Cognito Configuration = +// =========================== +export type CognitoConfig = { + scopes?: string; + clientId?: string; + authDomain?: string; +}; + +// =========================== +// = CSRF Tokens = +// =========================== +export const createAuthCsrfState = (): string => { + const state = uuidv4(); + + sessionStorage.setItem(AUTH_CSRF_STATE, state); // Specifically using sessionStorage for CSRF tokens + + return state; +}; + +export const consumeAuthCsrfState = (): string | null => { + const state = sessionStorage.getItem(AUTH_CSRF_STATE); + + sessionStorage.removeItem(AUTH_CSRF_STATE); + + return state; +}; + +// ============================ +// = PKCE = +// ============================ +const base64UrlEncode = (bytes: Uint8Array): string => { + let binary = ''; + + bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); + + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +}; + +const generatePkceCodeVerifier = (): string => { + const randomBytes = new Uint8Array(32); + + crypto.getRandomValues(randomBytes); + + return base64UrlEncode(randomBytes); +}; + +const generatePkceCodeChallenge = async (codeVerifier: string): Promise => { + const data = new TextEncoder().encode(codeVerifier); + const digest = await crypto.subtle.digest('SHA-256', data); + + return base64UrlEncode(new Uint8Array(digest)); +}; + +export const createPkceChallenge = async (): Promise => { + const codeVerifier = generatePkceCodeVerifier(); + const codeChallenge = await generatePkceCodeChallenge(codeVerifier); + + sessionStorage.setItem(AUTH_PKCE_CODE_VERIFIER, codeVerifier); // Specifically using sessionStorage for PKCE + + return codeChallenge; +}; + +export const consumePkceCodeVerifier = (): string | null => { + const codeVerifier = sessionStorage.getItem(AUTH_PKCE_CODE_VERIFIER); + + sessionStorage.removeItem(AUTH_PKCE_CODE_VERIFIER); + + return codeVerifier; +}; + +// =========================== +// = OAuth Scopes = +// =========================== +export const staffLoginScopes = 'email openid phone profile aws.cognito.signin.user.admin'; +export const licenseeLoginScopes = 'email openid phone profile aws.cognito.signin.user.admin'; + +// =========================== +// = Login URI Setup = +// =========================== +export const getCognitoConfig = (appMode: AppModes, authType: AuthTypes): CognitoConfig => { + const config: CognitoConfig = { + scopes: '', + clientId: '', + authDomain: '', + }; + + switch (authType) { + case AuthTypes.STAFF: + config.scopes = staffLoginScopes; + + if (appMode === AppModes.JCC) { + config.clientId = envConfig.cognitoClientIdStaff; + config.authDomain = envConfig.cognitoAuthDomainStaff; + } else if (appMode === AppModes.COSMETOLOGY) { + config.clientId = envConfig.cognitoClientIdStaffCosmo; + config.authDomain = envConfig.cognitoAuthDomainStaffCosmo; + } else if (appMode === AppModes.SOCIAL_WORK) { + config.clientId = envConfig.cognitoClientIdStaffSw; + config.authDomain = envConfig.cognitoAuthDomainStaffSw; + } + + break; + case AuthTypes.LICENSEE: + config.scopes = licenseeLoginScopes; + config.clientId = envConfig.cognitoClientIdLicensee; + config.authDomain = envConfig.cognitoAuthDomainLicensee; + break; + default: + break; + } + + return config; +}; + +export const getHostedLoginUri = (appMode: AppModes, authType: AuthTypes, hostedIdpPath = '/login', state = '', codeChallenge = ''): string => { + const { domain } = envConfig; + const { + scopes, + clientId, + authDomain + } = getCognitoConfig(appMode, authType); + const getCallbackPath = () => { + let userScopePath = ``; + let compactScopePath = ``; + + switch (authType) { + case AuthTypes.STAFF: + userScopePath += `/staff`; + break; + case AuthTypes.LICENSEE: + userScopePath += `/licensee`; + break; + default: + break; + } + + switch (appMode) { + case AppModes.JCC: + compactScopePath += `/jcc`; + break; + case AppModes.COSMETOLOGY: + compactScopePath += `/cosmo`; + break; + case AppModes.SOCIAL_WORK: + compactScopePath += `/socialwork`; + break; + default: + break; + } + + return `/auth/callback${userScopePath}${compactScopePath}`; + }; + const loginUriQuery = [ + `?client_id=${clientId}`, + `&response_type=code`, + `&scope=${encodeURIComponent(scopes || '')}`, + `&state=${encodeURIComponent(state)}`, + `&code_challenge=${encodeURIComponent(codeChallenge)}`, + `&code_challenge_method=S256`, + `&redirect_uri=${encodeURIComponent(`${domain}${getCallbackPath()}`)}`, + ].join(''); + const loginUri = `${authDomain}${hostedIdpPath}${loginUriQuery}`; + + return loginUri; +}; + +// ==================== +// = Auto logout = +// ==================== +export const autoLogoutConfig = { + INACTIVITY_TIMER_DEFAULT_MS: moment.duration(10, 'minutes').asMilliseconds(), + INACTIVITY_TIMER_STAFF_MS: moment.duration(10, 'minutes').asMilliseconds(), + INACTIVITY_TIMER_LICENSEE_MS: moment.duration(10, 'minutes').asMilliseconds(), + GRACE_PERIOD_MS: moment.duration(30, 'seconds').asMilliseconds(), + LOG: (message = '') => { + const isEnabled = false; // Helper logging for auto-logout testing + + if (isEnabled) { + console.log(`auto-logout: ${message}`); + } + }, +}; + +export default { + authStorage, + tokens, + AUTH_TYPE, + AUTH_LOGIN_GOTO_PATH, + AUTH_LOGIN_GOTO_PATH_AUTH_TYPE, + AUTH_LOGIN_GOTO_COMPACT, + AUTH_CSRF_STATE, + AUTH_PKCE_CODE_VERIFIER, + AuthTypes, + staffLoginScopes, + licenseeLoginScopes, + getCognitoConfig, + getHostedLoginUri, + createAuthCsrfState, + consumeAuthCsrfState, + createPkceChallenge, + consumePkceCodeVerifier, + autoLogoutConfig, +}; diff --git a/webroot/tests/helpers/setup.ts b/webroot/tests/helpers/setup.ts index 73886bf502..541f2ce49a 100644 --- a/webroot/tests/helpers/setup.ts +++ b/webroot/tests/helpers/setup.ts @@ -20,6 +20,7 @@ import moment from 'moment'; import momentTz from 'moment-timezone'; import sinon from 'sinon'; import { VirtualConsole } from 'jsdom'; +import * as nodeCrypto from 'crypto'; // Stabilize browser language default for tests try { @@ -57,6 +58,17 @@ window.matchMedia = sinon.stub().callsFake((query) => ({ dispatchEvent: sinon.spy(), })); +// Polyfill WebCrypto SubtleCrypto for tests (jsdom does not implement crypto.subtle, used for PKCE hashing) +try { + const { webcrypto } = (nodeCrypto as any); + + if (!globalThis.crypto?.subtle && webcrypto) { + Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true, writable: true }); + } +} catch (err) { + // ignore if not configurable in this environment +} + // Silence JSDOM bug of not implementing navigation but also not supporting config or suppression // https://github.com/jsdom/jsdom/issues/2112#issuecomment-673540137 declare global { diff --git a/webroot/tsconfig.json b/webroot/tsconfig.json index 96f71c4eca..7beee3e066 100644 --- a/webroot/tsconfig.json +++ b/webroot/tsconfig.json @@ -32,6 +32,7 @@ "@router/*": [ "src/router/*" ], "@store/*": [ "src/store/*" ], "@styles.common/*": [ "src/styles.common/*" ], + "@utils/*": [ "src/utils/*" ], "@tests/*": [ "tests/*" ], }, "lib": [ diff --git a/webroot/vue.config.js b/webroot/vue.config.js index 0268d57dd2..6907e98e5d 100644 --- a/webroot/vue.config.js +++ b/webroot/vue.config.js @@ -316,6 +316,7 @@ module.exports = { '@router': path.join(__dirname, '/src/router'), '@store': path.join(__dirname, '/src/store'), '@styles.common': path.join(__dirname, '/src/styles.common/'), + '@utils': path.join(__dirname, '/src/utils'), '@tests': path.join(__dirname, '/tests'), }, },