Repository: oppia/oppia

Issue:
[BUG]: signInWithRedirect() will not work on certain browsers
### Describe the bug

We received a notification from Firebase (which we use for sign-in) that there are some issues using signInWithRedirect() in our app. **Users using certain browsers are not able to sign in to Oppia.**

We probably need to switch to signInWithPopup() instead (option 2 here): https://firebase.google.com/docs/auth/web/redirect-best-practices#signinwithpopup

### Steps To Reproduce

End users may have difficulty authenticating to Oppia if they are using one of:

- Safari 16.1+ on macOS
- iOS 16.1+
- Firefox 109+
- Chrome M115+

### Expected Behavior

We should use signInWithPopup() and confirm that it works fine with the browsers above.

### Screenshots/Videos

_No response_

### What device are you using?

_No response_

### Operating System

_No response_

### What browsers are you seeing the problem on?

_No response_

### Browser version

_No response_

### Additional context

_No response_


Discussion:
Hii @seanlip it'll really help me to triage it if I can know the percent of users facing this issue? 
I'd estimate about 6-7% based on analytics (which actually makes this issue rather serious since it stops them from using a large amount of the site). Note that this is just based on the browsers, not the versions, but it'll get worse over time as people upgrade.
Thankyou @seanlip!
Hi @seanlip , In production we use `signInWithRedirect()` but on development server we use `signInWithEmailAndPassword()` so if I want to take up this issue and try to change the auth type how should I proceed with it? If I start the server in prod mode I know it will cost my pc more resources which might make the deveelopment process slow. It'll be great if you can suggest me a way on how to proceed with this?

Also while debugging as I am not using a Apple product I cannot use Safari to test if the different signin method works perfectly. I can test on firefox though as the issue seems to appear on firefox as well

Thanks!
@kshitij01042002 I think you'll need to keep the development server code as is, and we'll need to test this on production. If you replace signInWithRedirect() with signInWithPopup() instead, then you can submit a request following [these instructions](https://github.com/oppia/oppia/wiki/Testing-jobs-and-other-features-on-production) so that we can check that things work on production. When you file this please make sure that your instructions include testing in Safari so that we can give you a report on that case as well.

For local development, my suggestion is to temporarily hook up "dev mode" to the new signInWithPopup() method so that you can avoid needing to set things up in prod mode locally, then switch it over when you make the PR. That will enable you to test out the new method locally.

I hope this helps, and please ask if you have more questions. Thanks for taking a look at this issue!
Thanks @seanlip will start working with this!
Hi @kshitij01042002 
Could you please provide me an expected date when you plan to open the PR?
thanks!
Hi @SubhashThenua , I expect to open the PR till 20/04/23.
Thabks
Hi @SubhashThenua , I expect to open the PR till 20/04/23.
Thanks.
Hi @seanlip , you suggested we should replace [signInWithRedirect()](https://firebase.google.com/docs/reference/js/auth.md#signinwithredirect) with [signInWithPopup()](https://firebase.google.com/docs/reference/js/auth.md#signinwithpopup) but in the google firebase authentication docs we can see a note stating that Popup is not a ideal signin method so implementing it can cause breakage so how should I proceed with it?
![image](https://user-images.githubusercontent.com/91470808/233713181-b84b5fc2-c7c3-4df1-a090-ea154ef7ab02.png)

It's not ideal, but it might be the best option we have.
Hi @seanlip I have implemented the signInWithPopUp and raised the PR for the same,

Thanks @SubhashThenua for helping in debugging and testing the changes in the development server. You can also follow the updates on this [PR](https://github.com/oppia/oppia/pull/18132).
@seanlip is oppia not hosted on firebase?
It's hosted on Google Cloud Platform (specifically, App Engine) but uses Firebase Auth for login.
Currently exploring other ways to fix the login issue instead of using pop ups because some browsers block popups and it's not ideal for mobile users. I might be able to [manually redirect auth requests to the firebase server](https://firebase.google.com/docs/auth/web/redirect-best-practices#proxy-requests).

Resolving patch:
diff --git a/core/controllers/firebase.py b/core/controllers/firebase.py
new file mode 100644
--- /dev/null
+++ b/core/controllers/firebase.py
@@ -0,0 +1,100 @@
+# Copyright 2023 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Firebase proxy controller."""
+
+from __future__ import annotations
+
+import json
+
+from core import feconf
+from core.controllers import acl_decorators
+from core.controllers import base
+
+import requests
+from typing import Any, Dict
+
+
+TIMEOUT_SECS = 60
+"""Timeout in seconds for firebase requests."""
+
+FIREBASE_DOMAINS = {
+    'oppiaserver-backup-migration.appspot.com':
+        'https://oppiaserver-backup-migration.firebaseapp.com',
+    'www.oppiatestserver.org': 'https://oppiatestserver.firebaseapp.com',
+    'www.oppia.org': 'https://oppiaserver.firebaseapp.com'
+}
+"""A mapping of oppia domain to firebase domains used for authentication."""
+
+
+class FirebaseProxyPage(
+    base.BaseHandler[Dict[str, str], Dict[str, str]]
+):
+    """Handler to proxy auth requests to the firebase domain."""
+
+    GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_HTML
+    URL_PATH_ARGS_SCHEMAS = {
+        'firebase_path': {
+            'schema': {
+                'type': 'basestring'
+            }
+        }
+    }
+    HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}, 'POST': {}}
+    RESPONSE_EXCLUDED_HEADERS = frozenset([
+        'content-encoding',
+        'content-length',
+        'transfer-encoding',
+        'connection'
+    ])
+    """Headers to exclude from the packaged response."""
+
+    def _firebase_proxy(self) -> None:
+        """Proxies all requests to the firebase app."""
+        firebase_domain = FIREBASE_DOMAINS.get(self.request.domain)
+        if firebase_domain is None:
+            raise KeyError(
+                f'No firebase domain found for {self.request.domain}.'
+            )
+
+        data = json.loads(self.request.body) if self.request.body else None
+        response = requests.request(
+            self.request.method,
+            f'{firebase_domain}{self.request.path}',
+            params=dict(self.request.params),
+            data=data,
+            headers=dict(self.request.headers.items()),
+            timeout=TIMEOUT_SECS
+        )
+
+        for header_key, header_value in response.headers.items():
+            if header_key.lower() in self.RESPONSE_EXCLUDED_HEADERS:
+                continue
+            self.response.headers[header_key] = header_value
+        self.response.status = response.status_code
+        self.response.body = response.content
+
+    # Here we use type Any because we accept any number/type of args
+    # to accomodate all firebase requests.
+    @acl_decorators.open_access
+    def get(self, *args: Any, **kwargs: Any) -> None:  # pylint: disable=unused-argument
+        """Proxies GET requests to the firebase app."""
+        self._firebase_proxy()
+
+    # Here we use type Any because we accept any number/type of args
+    # to accomodate all firebase requests.
+    @acl_decorators.open_access
+    def post(self, *args: Any, **kwargs: Any) -> None:  # pylint: disable=unused-argument
+        """Proxies POST requests to the firebase app."""
+        self._firebase_proxy()
diff --git a/main.py b/main.py
--- a/main.py
+++ b/main.py
@@ -46,6 +46,7 @@
 from core.controllers import features
 from core.controllers import feedback
 from core.controllers import feedback_updates
+from core.controllers import firebase
 from core.controllers import improvements
 from core.controllers import incoming_app_feedback_report
 from core.controllers import learner_dashboard
@@ -213,6 +214,10 @@ def get_redirect_route(
 
 # Register the URLs with the classes responsible for handling them.
 URLS = [
+    get_redirect_route(
+        '/<firebase_path:__/auth(?:/.*)?>',
+        firebase.FirebaseProxyPage
+    ),
     get_redirect_route(r'/_ah/warmup', WarmupPage),
     get_redirect_route(r'/splash', SplashRedirectPage),
     get_redirect_route(
diff --git a/scripts/linters/codeowner_linter.py b/scripts/linters/codeowner_linter.py
--- a/scripts/linters/codeowner_linter.py
+++ b/scripts/linters/codeowner_linter.py
@@ -57,6 +57,7 @@
     '/core/controllers/acl_decorators*.py',
     '/core/controllers/android*.py',
     '/core/controllers/base*.py',
+    '/core/controllers/firebase*.py',
     '/core/domain/android*.py',
     '/core/domain/html*.py',
     '/core/domain/rights_manager*.py',


Test patch:
diff --git a/core/controllers/firebase_test.py b/core/controllers/firebase_test.py
new file mode 100644
--- /dev/null
+++ b/core/controllers/firebase_test.py
@@ -0,0 +1,117 @@
+# Copyright 2023 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for the firebase controllers."""
+
+from __future__ import annotations
+
+import collections
+
+from core.controllers import firebase
+from core.tests import test_utils
+
+import requests
+
+
+class FirebaseProxyPageTest(test_utils.GenericTestBase):
+    """Tests for FirebaseProxyPage."""
+
+    MockResponse = collections.namedtuple(
+        'MockResponse',
+        ['headers', 'status_code', 'content']
+    )
+    MOCK_FIREBASE_RESPONSE = MockResponse(
+        {
+            'Content-Type': 'application/json',
+            'Res-Header': 'value',
+            'Connection': 'connection_value'
+        },
+        200,
+        b')]}\'\n{"key": "val"}'
+    )
+    MOCK_FIREBASE_DOMAIN = 'https://mock.firebaseapp.com'
+
+    def test_no_firebase_domain_error_raised(self) -> None:
+        response = self.post_task(
+            '/__/auth/', {'a': 'a'},
+            {'b': 'b'}, expected_status_int=500
+        )
+        self.assertIn(
+            b'No firebase domain found for localhost',
+            response.body
+        )
+
+    def test_get_request_forwarded_to_firebase_proxy(self) -> None:
+        url = '/__/auth'
+        params = {'param_1': 'value_1', 'param_2': 'value_2'}
+        with self.swap(
+            firebase, 'FIREBASE_DOMAINS',
+            {'localhost': self.MOCK_FIREBASE_DOMAIN}
+        ), self.swap_with_checks(
+            requests,
+            'request',
+            lambda *args, **kwargs: self.MOCK_FIREBASE_RESPONSE,
+            [('GET', f'{self.MOCK_FIREBASE_DOMAIN}{url}')],
+            [{
+            'params': params, 'timeout': firebase.TIMEOUT_SECS,
+            'data': None, 'headers': {'Host': 'localhost:80'}
+            }]
+        ):
+            response = self.get_json(url, params)
+            self.assertDictEqual(response, {'key': 'val'})
+
+    def test_post_request_forwarded_to_firebase_proxy(self) -> None:
+        url = '/__/auth/random_url'
+        headers = {
+            'Req-Header': 'value',
+            'Host': 'localhost:80',
+            'Content-Type': 'application/json',
+            'Content-Length': '20'
+        }
+        payload = {'payload': 'value'}
+        with self.swap(
+            firebase, 'FIREBASE_DOMAINS',
+            {'localhost': self.MOCK_FIREBASE_DOMAIN}
+        ), self.swap_with_checks(
+            requests,
+            'request',
+            lambda *args, **kwargs: self.MOCK_FIREBASE_RESPONSE,
+            [('POST', f'{self.MOCK_FIREBASE_DOMAIN}{url}')],
+            [{
+            'data': payload, 'params': {},
+            'headers': headers, 'timeout': firebase.TIMEOUT_SECS
+            }]
+        ):
+            response = self.post_task(
+                url,
+                payload,
+                headers
+            )
+
+            for header, value in self.MOCK_FIREBASE_RESPONSE.headers.items():
+                if (
+                    header.lower() in
+                    firebase.FirebaseProxyPage.RESPONSE_EXCLUDED_HEADERS
+                ):
+                    self.assertNotIn(header, response.headers)
+                else:
+                    self.assertEqual(response.headers[header], value)
+            self.assertEqual(
+                response.status_int,
+                self.MOCK_FIREBASE_RESPONSE.status_code
+            )
+            self.assertEqual(
+                response.body,
+                self.MOCK_FIREBASE_RESPONSE.content
+            )
diff --git a/core/tests/test_utils.py b/core/tests/test_utils.py
--- a/core/tests/test_utils.py
+++ b/core/tests/test_utils.py
@@ -3053,7 +3053,7 @@ def post_task(
         self,
         url: str,
         payload: Dict[str, str],
-        headers: Dict[str, bytes],
+        headers: Dict[str, bytes] | Dict[str, str],
         csrf_token: Optional[str] = None,
         expect_errors: bool = False,
         expected_status_int: int = 200
diff --git a/scripts/backend_test_shards.json b/scripts/backend_test_shards.json
--- a/scripts/backend_test_shards.json
+++ b/scripts/backend_test_shards.json
@@ -5,6 +5,7 @@
         "core.controllers.base_test",
         "core.controllers.collection_editor_test",
         "core.controllers.editor_test",
+        "core.controllers.firebase_test",
         "core.controllers.learner_playlist_test",
         "core.controllers.review_tests_test",
         "core.controllers.resources_test",