snowflakedb / snowflakedb/snowflake-connector-python

SNOW-3791018: TokenKey constructed with swapped host/user positional arguments in auth/_auth.py

Open
#2,931 1 comment 0 reactions 1 assignee View on GitHub

@sfc-gh-snow-drivers-warsaw-dl is already working on this.

Since Jul 16, 2026.

bug status-triage_done
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.