Repository: zulip/zulip

Issue:
Fix error handling for expired tokens in desktop app login flow
Following https://github.com/zulip/zulip/pull/14747, if you complete the desktop app login flow to login the desktop app, and on the landing page (see https://github.com/zulip/zulip/issues/14827) and click "Continue in browser"), we'll try reusing the login token, which is forbidden.  This results in the following broken page:

![image](https://user-images.githubusercontent.com/2746074/80769532-5c1fba00-8b02-11ea-9cea-57e92464becd.png)

(This can happen both in the desktop app and the server).

We could clean up the page, but @mateuszmandera is there a reason we don't just want to redirect back to /login in this expired case case?  (@andersk FYI).

A few other questions to think a bit about:
* How long do we want to give users to use the token?  
* Does it make sense to allow the token to be used more than once?  (Or the token to be used once and the link to be used once?)  Then a user who wants to login to both the desktop app and browser could do that in one login transaction.




Discussion:
> but @mateuszmandera is there a reason we don't just want to redirect back to /login in this expired case case?

I'd say that it's much nicer to get the error page and a link to click to try to log in again, than be taken to the login page immediately not knowing what happened :thinking: 

For the main issue, let's just allow multiple uses of the token then? And a time limit of - I'm not sure what the right amount is - let's say 1 minute sounds reasonable?
I think 1 minute is reasonable, and I think given the 1-minute time limit, the main concern one would have with multiple uses of the token would be that a MITM attacker could use it to get a duplicate login session, perhaps?

@andersk what are your thoughts?
Yes, that's the main concern. We could generate two one-time tokens, one for the app and one for the "continue in browser" link.
My view is that opening the app just to log in the browser is a convoluted use case that nobody would think of if the “continue in browser” link wasn’t specifically presented to them. It’s good to have it so that if they *didn’t* open the app and are instead getting phished for the token, they have an obvious safe alternative. We should make sure it doesn’t feel broken, but we don’t need to go out of our way to make it unexpectedly extra-convenient. Toward that end, “continue in browser” could just link directly to the login page with no token magic.
>  It’s good to have it so that if they didn’t open the app and are instead getting phished for the token, they have an obvious safe alternative

In this scenario, the browser link with one-time token will work, so things won't feel broken. To get the "broken" feel one would have to first log in the app and then use the browser link.

Making two one-time tokens to make everything convenient and reliable should be as simple as:
```diff
diff --git a/zerver/views/auth.py b/zerver/views/auth.py
index a11dc9a363..75623ebe49 100644
--- a/zerver/views/auth.py
+++ b/zerver/views/auth.py
@@ -269,12 +269,15 @@ def finish_desktop_flow(request: HttpRequest, user_profile: UserProfile,
     of being created, as nothing more powerful is needed for the desktop flow
     and this ensures the key can only be used for completing this authentication attempt.
     """
-    result = ExternalAuthResult(user_profile=user_profile)
-    token = result.store_data()
+    result_for_desktop = ExternalAuthResult(user_profile=user_profile)
+    token_for_desktop = result.store_data()
     key = bytes.fromhex(otp)
     iv = os.urandom(12)
-    desktop_data = (iv + AESGCM(key).encrypt(iv, token.encode(), b"")).hex()
-    browser_url = user_profile.realm.uri + reverse('zerver.views.auth.log_into_subdomain', args=[token])
+    desktop_data = (iv + AESGCM(key).encrypt(iv, token_for_desktop.encode(), b"")).hex()
+
+    result_for_browser = ExternalAuthResult(user_profile=user_profile)
+    token_for_browser = result_for_browser.store_data()
+    browser_url = user_profile.realm.uri + reverse('zerver.views.auth.log_into_subdomain', args=[token_for_desktop])
     context = {'desktop_data': desktop_data,
                'browser_url': browser_url,
                'realm_icon_url': realm_icon_url(user_profile.realm)}
```

so perhaps we should go with that?
Yeah, but *not* making two one-time tokens is even simpler, so it’s reasonable to ask what use case they’re serving and whether they actually convenience anyone. Wouldn’t someone who wanted to log in with the browser start by opening the browser, not by opening the app and starting a flow that clearly indicates an intent to log in with the app?
Yeah, I think I agree simpler is better.  I think redirecting you back to /login in the event the token is expired is a totally reasonable answer..

Resolving patch:
diff --git a/zerver/views/auth.py b/zerver/views/auth.py
--- a/zerver/views/auth.py
+++ b/zerver/views/auth.py
@@ -283,9 +283,9 @@ def finish_desktop_flow(request: HttpRequest, user_profile: UserProfile,
     key = bytes.fromhex(otp)
     iv = os.urandom(12)
     desktop_data = (iv + AESGCM(key).encrypt(iv, token.encode(), b"")).hex()
-    browser_url = user_profile.realm.uri + reverse('zerver.views.auth.log_into_subdomain', args=[token])
     context = {'desktop_data': desktop_data,
-               'browser_url': browser_url,
+               'browser_url': reverse('zerver.views.auth.login_page',
+                                      kwargs = {'template_name': 'zerver/login.html'}),
                'realm_icon_url': realm_icon_url(user_profile.realm)}
     return render(request, 'zerver/desktop_redirect.html', context=context)
 


Test patch:
diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py
--- a/zerver/tests/test_auth_backends.py
+++ b/zerver/tests/test_auth_backends.py
@@ -664,10 +664,10 @@ def verify_desktop_flow_end_page(self, response: HttpResponse, email: str,
         desktop_data = soup.find("input", value=True)["value"]
         browser_url = soup.find("a", href=True)["href"]
 
+        self.assertEqual(browser_url, '/login/')
         decrypted_key = self.verify_desktop_data_and_return_key(desktop_data, desktop_flow_otp)
-        self.assertEqual(browser_url, f'http://zulip.testserver/accounts/login/subdomain/{decrypted_key}')
 
-        result = self.client_get(browser_url)
+        result = self.client_get(f'http://zulip.testserver/accounts/login/subdomain/{decrypted_key}')
         self.assertEqual(result.status_code, 302)
         realm = get_realm("zulip")
         user_profile = get_user_by_delivery_email(email, realm)