Repository: alexgolec/tda-api

Issue:
authentication failure
Hey

I've created an app on TD Ameritrade with a callback URL `https://127.0.0.1:8080` (as [suggested](https://developer.tdameritrade.com/content/authentication-faq) on developer pages). I am making sure I am sending the correct API_KEY and redirect_url to the authentication flow

```
with webdriver.Chrome() as driver:
    c = auth.client_from_login_flow(driver, apikey, redirect_uri, token_path)
```

However, I am getting the error page `a third party application` [you document](https://tda-api.readthedocs.io/en/latest/auth.html). 

Logging shows the port on the localhost being sent with the GET is different however

```
2020-06-08 16:48:47,541 DEBUG    GET http://127.0.0.1:57214/session/<SESSION>/url {}
2020-06-08 16:48:47,544 DEBUG    http://127.0.0.1:57214 "GET /session/<SESSION>/url HTTP/1.1" 200 189
2020-06-08 16:48:47,544 DEBUG    Finished Request
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/alexgolec/tda-api/pull/37?src=pr&el=h1) Report
> Merging [#37](https://codecov.io/gh/alexgolec/tda-api/pull/37?src=pr&el=desc) into [master](https://codecov.io/gh/alexgolec/tda-api/commit/c9a9a9e0ff215aec66af2a622170e0aad8558a5f&el=desc) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/alexgolec/tda-api/pull/37/graphs/tree.svg?width=650&height=150&src=pr&token=M8H98HA8CK)](https://codecov.io/gh/alexgolec/tda-api/pull/37?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master      #37   +/-   ##
=======================================
  Coverage   99.31%   99.31%           
=======================================
  Files          11       11           
  Lines        1600     1601    +1     
=======================================
+ Hits         1589     1590    +1     
  Misses         11       11           
```


| [Impacted Files](https://codecov.io/gh/alexgolec/tda-api/pull/37?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [tda/auth.py](https://codecov.io/gh/alexgolec/tda-api/pull/37/diff?src=pr&el=tree#diff-dGRhL2F1dGgucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/alexgolec/tda-api/pull/37?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/alexgolec/tda-api/pull/37?src=pr&el=footer). Last update [c9a9a9e...090dc7d](https://codecov.io/gh/alexgolec/tda-api/pull/37?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/tda/auth.py b/tda/auth.py
index d9c7976..105dadb 100644
--- a/tda/auth.py
+++ b/tda/auth.py
@@ -58,6 +58,7 @@ def client_from_token_file(token_path, api_key):
     __register_token_redactions(token)
 
     # Return a new session configured to refresh credentials
+    api_key = __normalize_api_key(api_key)
     return Client(
         api_key,
         OAuth2Session(api_key, token=token,
@@ -89,6 +90,8 @@ def client_from_login_flow(webdriver, api_key, redirect_url, token_path,
     get_logger().info(('Creating new token with redirect URL \'{}\' ' +
                        'and token path \'{}\'').format(redirect_url, token_path))
 
+    api_key = __normalize_api_key(api_key)
+
     oauth = OAuth2Session(api_key, redirect_uri=redirect_url)
     authorization_url, state = oauth.authorization_url(
         'https://auth.tdameritrade.com/auth')
@@ -155,7 +158,6 @@ def easy_client(api_key, redirect_uri, token_path, webdriver_func=None):
                            a new token. Will only be called if the token file
                            cannot be found.
     '''
-    api_key = __normalize_api_key(api_key)
     logger = get_logger()
 
     try:


Test patch:
diff --git a/tests/auth_test.py b/tests/auth_test.py
index d9457ed..ebf9d43 100644
--- a/tests/auth_test.py
+++ b/tests/auth_test.py
@@ -9,7 +9,7 @@ import tempfile
 import unittest
 
 
-API_KEY = 'APIKEY'
+API_KEY = 'APIKEY@AMER.OAUTHAP'
 
 
 class ClientFromTokenFileTest(unittest.TestCase):
@@ -46,6 +46,24 @@ class ClientFromTokenFileTest(unittest.TestCase):
             auto_refresh_kwargs=_,
             token_updater=_)
 
+    @no_duplicates
+    @patch('tda.auth.Client')
+    @patch('tda.auth.OAuth2Session')
+    def test_api_key_is_normalized(self, session, client):
+        self.write_token()
+
+        client.return_value = 'returned client'
+
+        self.assertEqual('returned client',
+                         auth.client_from_token_file(self.pickle_path, 'API_KEY'))
+        client.assert_called_once_with('API_KEY@AMER.OAUTHAP', _)
+        session.assert_called_once_with(
+            'API_KEY@AMER.OAUTHAP',
+            token=self.token,
+            auto_refresh_url=_,
+            auto_refresh_kwargs=_,
+            token_updater=_)
+
 
 REDIRECT_URL = 'https://redirect.url.com'
 
@@ -82,6 +100,32 @@ class ClientFromLoginFlow(unittest.TestCase):
         with open(self.pickle_path, 'rb') as f:
             self.assertEqual(self.token, pickle.load(f))
 
+    @no_duplicates
+    @patch('tda.auth.Client')
+    @patch('tda.auth.OAuth2Session')
+    def test_normalize_api_key(self, session_constructor, client):
+        AUTH_URL = 'https://auth.url.com'
+
+        session = MagicMock()
+        session_constructor.return_value = session
+        session.authorization_url.return_value = AUTH_URL, None
+        session.fetch_token.return_value = self.token
+
+        webdriver = MagicMock()
+        webdriver.get.return_value = REDIRECT_URL + '/token_params'
+
+        client.return_value = 'returned client'
+
+        self.assertEqual('returned client',
+                         auth.client_from_login_flow(
+                             webdriver, 'API_KEY', REDIRECT_URL,
+                             self.pickle_path,
+                             redirect_wait_time_seconds=0.0))
+
+        self.assertEqual(
+                'API_KEY@AMER.OAUTHAP',
+                session_constructor.call_args[0][0])
+
 
 class EasyClientTest(unittest.TestCase):