snowflakedb / snowflakedb/snowflake-connector-python
SNOW-3791018: TokenKey constructed with swapped host/user positional arguments in auth/_auth.py
@sfc-gh-snow-drivers-warsaw-dl is already working on this.
Since Jul 16, 2026.
- Dominant language
- Python
- Stars
- 730
- Forks
- 574
- Avg merge
- 5h 45m
- Merged PRs (30d)
- 16
Description
Python version
Python 3.13.3 (not version-specific; this is a source-level inconsistency)
Operating system and processor architecture
Windows 11 x64 (affects all platforms that use the keyring token cache)
Installed packages
Observed in `snowflake-connector-python` 3.18.0; the same code is on current `main` (4.7.x).
What did you do?
While debugging #2930 I inspected what the token cache actually writes, and noticed the two call sites that build TokenKey disagree about argument order.
The dataclass is declared (user, host, tokenType):
@dataclass
class TokenKey:
user: str
host: str
tokenType: TokenType
`auth/_auth.py` constructs it positionally as `TokenKey(host, user, cred_type)` — host lands in `user` and vice versa — in `_read_temporary_credential`, `_write_temporary_credential`, and `_delete_temporary_credential`:
return self.get_token_cache().retrieve(TokenKey(host, user, cred_type))
...
self.get_token_cache().store(TokenKey(host, user, cred_type), cred)
...
self.get_token_cache().remove(TokenKey(host, user, cred_type))
Meanwhile `auth/_oauth_base.py` passes the declared order:
TokenKey(self._user, self._idp_host, TokenType.OAUTH_ACCESS_TOKEN)
Since `string_key()` is `f"{host.upper()}:{user.upper()}:{tokenType}"`, the externalbrowser/MFA entries end up keyed as `USER:HOST:TOKEN_TYPE` while OAuth entries are keyed `HOST:USER:TOKEN_TYPE`. I confirmed this empirically on 3.18.0 by inspecting a file-based keyring after running both flows:
[CALM@EXAMPLE.COM:MYACCOUNT.EU-CENTRAL-1.SNOWFLAKECOMPUTING.COM:ID_TOKEN] <- user:host, swapped
[MYACCOUNT.EU-CENTRAL-1.SNOWFLAKECOMPUTING.COM:CALM:OAUTH_ACCESS_TOKEN] <- host:user, correct
Nothing is functionally broken today because store and retrieve agree with each other within each path. But:
- entries written by `_auth.py` don't match the layout other Snowflake drivers or tooling would expect;
- the new hashed-key layout on `main` inherits the same swap (the hash input is `USER:HOST:...` for one path and `HOST:USER:...` for the other), and `_retrieve_legacy` migrates the swapped layout as-is;
- any future migration or interop code assuming the documented `HOST:USER:TOKEN_TYPE` layout will silently miss these entries.
What did you expect to see?
Both call sites using the same field order. The low-risk fix is keyword arguments in _auth.py:
TokenKey(user=user, host=host, tokenType=cred_type)
plus a unit test asserting string_key() produces HOST:USER:TOKEN_TYPE for keys built the way _auth.py builds them. Note this changes the cache key for previously stored ID/MFA tokens, so users would see one extra authentication prompt after upgrading while the cache re-populates.
Can you set logging to DEBUG and collect the logs?
There is nothing useful in the DEBUG logs for this one, the connector logs nothing on the
token-store path ,so instead here is a recording-backend run that captures exactly what each
code path writes to the keyring (connector 3.18.0; same construction sites exist on `main`).
The script installs an in-memory keyring backend that records `(service, account)` for every
`set_password`, then drives the real `Auth._write_temporary_credential` (the
externalbrowser/MFA path) and the OAuth-order construction from `auth/_oauth_base.py`:
from unittest.mock import MagicMock
import keyring, keyring.backend
from snowflake.connector.auth._auth import Auth
from snowflake.connector.token_cache import KeyringTokenCache, TokenKey, TokenType
records = []
class RecordingKeyring(keyring.backend.KeyringBackend):
priority = 1
def set_password(self, service, username, password):
records.append((service, username))
def get_password(self, service, username):
return None
def delete_password(self, service, username):
pass
keyring.set_keyring(RecordingKeyring())
HOST = "MYACCOUNT.EU-CENTRAL-1.SNOWFLAKECOMPUTING.COM"
USER = "ALICE"
# path 1: auth/_auth.py -- Auth._write_temporary_credential(host, user, ...)
auth = Auth(MagicMock())
auth._token_cache = KeyringTokenCache()
auth._write_temporary_credential(HOST, USER, TokenType.ID_TOKEN, "dummy_id_token")
# path 2: auth/_oauth_base.py construction order -- TokenKey(user, host, ...)
KeyringTokenCache().store(TokenKey(USER, HOST, TokenType.OAUTH_ACCESS_TOKEN), "dummy_access_token")
for service, account in records:
print(f"service = {service}\naccount = {account}\n")
Output:
python: 3.13.14 [MSC v.1944 64 bit (AMD64)]
platform: Windows-11-10.0.26200-SP0
snowflake-connector-python: 3.18.0
service = ALICE:MYACCOUNT.EU-CENTRAL-1.SNOWFLAKECOMPUTING.COM:ID_TOKEN
account = MYACCOUNT.EU-CENTRAL-1.SNOWFLAKECOMPUTING.COM
service = MYACCOUNT.EU-CENTRAL-1.SNOWFLAKECOMPUTING.COM:ALICE:OAUTH_ACCESS_TOKEN
account = ALICE
Same host, same user: the `_auth.py` path stores the ID_TOKEN under `USER:HOST:ID_TOKEN` with
the hostname in the account field, while the OAuth path produces the intended
`HOST:USER:OAUTH_ACCESS_TOKEN` with the username as the account.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.