Store cached OAuth token files with 0600 permissions - #6987
Conversation
Adds beets.util.open_secure(), which opens a file for writing with permissions restricted to the owner (0600) instead of relying on the process umask, creating it atomically with those permissions and tightening permissions on an existing file when it is reopened. Signed-off-by: Shivam <shivamssing25@gmail.com>
The cached Spotify access-token file is now created with owner-only (0600) permissions instead of the process umask's default. Signed-off-by: Shivam <shivamssing25@gmail.com>
The cached Discogs OAuth token/secret file is now created with owner-only (0600) permissions instead of the process umask's default. Signed-off-by: Shivam <shivamssing25@gmail.com>
The cached Beatport OAuth token/secret file is now created with owner-only (0600) permissions instead of the process umask's default. Signed-off-by: Shivam <shivamssing25@gmail.com>
The cached Tidal session token file is now created with owner-only (0600) permissions instead of the process umask's default. Signed-off-by: Shivam <shivamssing25@gmail.com>
Signed-off-by: Shivam <shivamssing25@gmail.com>
Covers new-file creation (owner-only permissions), tightening permissions on an existing file with looser permissions, and overwriting previous contents. Signed-off-by: Shivam <shivamssing25@gmail.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #6987 +/- ##
==========================================
+ Coverage 76.85% 76.87% +0.02%
==========================================
Files 163 163
Lines 21625 21615 -10
Branches 3343 3343
==========================================
- Hits 16619 16616 -3
+ Misses 4190 4184 -6
+ Partials 816 815 -1
🚀 New features to boost your workflow:
|
|
I have refactored this in a separate branch where I migrate the codebase to 0eae802de31bc281a44e9935a3e846cc951d086e
2026-08-24 08:45 / Šarūnas Nejus <snejus@protonmail.com>
2026-09-02 08:07 / Šarūnas Nejus <snejus@protonmail.com>
Replace os.path utils with pathlib
diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py
index 6e1efe494..5cc5334de 100644
--- a/beetsplug/beatport.py
+++ b/beetsplug/beatport.py
@@ -5,6 +5,7 @@
import json
import re
from datetime import datetime, timedelta
+from functools import cached_property
from typing import TYPE_CHECKING, Literal, overload
import confuse
@@ -26,6 +27,7 @@
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
+ from pathlib import Path
from beets.library import Item
@@ -327,8 +329,7 @@ def setup
# Get the OAuth token from a file or log in.
try:
- with open(self._tokenfile()) as f:
- tokendata = json.load(f)
+ tokendata = json.loads(self.tokenfile.read_text())
except OSError:
# No token yet. Generate one.
token, secret = self.authenticate(c_key, c_secret)
@@ -360,14 +361,16 @@ def authenticate
# Save the token for later use.
self._log.debug("Beatport token {}, secret {}", token, secret)
- with open(self._tokenfile(), "w") as f:
- json.dump({"token": token, "secret": secret}, f)
+ self.tokenfile.write_text(
+ json.dumps({"token": token, "secret": secret})
+ )
return token, secret
- def _tokenfile(self) -> str:
+ @cached_property
+ def tokenfile(self) -> Path:
"""Get the path to the JSON file for storing the OAuth token."""
- return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
+ return self.config["tokenfile"].get(confuse.Path(in_app_dir=True))
def candidates(
self, items: Sequence[Item], artist: str, album: str, va_likely: bool
diff --git a/beetsplug/discogs/__init__.py b/beetsplug/discogs/__init__.py
index a24e28f7a..2ebb4941f 100644
--- a/beetsplug/discogs/__init__.py
+++ b/beetsplug/discogs/__init__.py
@@ -6,7 +6,6 @@
import http.client
import json
-import os
import re
import socket
import time
@@ -32,6 +31,7 @@
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Sequence
+ from pathlib import Path
from beets.importer import ImportSession
from beets.library import Item
@@ -139,8 +139,7 @@ def setup
# Get the OAuth token from a file or log in.
try:
- with open(self._tokenfile()) as f:
- tokendata = json.load(f)
+ tokendata = json.loads(self.tokenfile.read_text())
except OSError:
# No token yet. Generate one.
token, secret = self.authenticate(c_key, c_secret)
@@ -152,12 +151,13 @@ def setup
def reset_auth(self) -> None:
"""Delete token file & redo the auth steps."""
- os.remove(self._tokenfile())
+ self.tokenfile.unlink()
self.setup()
- def _tokenfile(self) -> str:
+ @cached_property
+ def tokenfile(self) -> Path:
"""Get the path to the JSON file for storing the OAuth token."""
- return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
+ return self.config["tokenfile"].get(confuse.Path(in_app_dir=True))
def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
# Get the link for the OAuth page.
@@ -183,8 +183,9 @@ def authenticate
# Save the token for later use.
self._log.debug("Discogs token {}, secret {}", token, secret)
- with open(self._tokenfile(), "w") as f:
- json.dump({"token": token, "secret": secret}, f)
+ self.tokenfile.write_text(
+ json.dumps({"token": token, "secret": secret})
+ )
return token, secret
diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py
index e88070336..27a4062ff 100644
--- a/beetsplug/spotify.py
+++ b/beetsplug/spotify.py
@@ -12,6 +12,7 @@
import threading
import time
import webbrowser
+from functools import cached_property
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, TypedDict
@@ -28,6 +29,7 @@
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence
+ from pathlib import Path
from beets.library import Item, Library
from beets.metadata_plugins import QueryType, SearchParams
@@ -180,16 +182,16 @@ def setup
"""Retrieve previously saved OAuth token or generate a new one."""
try:
- with open(self._tokenfile()) as f:
- token_data = json.load(f)
+ token_data = json.loads(self.tokenfile.read_text())
except OSError:
self._authenticate()
else:
self.access_token = token_data["access_token"]
- def _tokenfile(self) -> str:
+ @cached_property
+ def tokenfile(self) -> Path:
"""Get the path to the JSON file for storing the OAuth token."""
- return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
+ return self.config["tokenfile"].get(confuse.Path(in_app_dir=True))
def _authenticate(self) -> None:
"""Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow"""
@@ -218,8 +220,9 @@ def _authenticate
# Save the token for later use.
self._log.debug("{0.data_source} access token: {0.access_token}", self)
- with open(self._tokenfile(), "w") as f:
- json.dump({"access_token": self.access_token}, f)
+ self.tokenfile.write_text(
+ json.dumps({"access_token": self.access_token})
+ )
def _handle_response(
self,
diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py
index 44e4481d3..7463a5cbb 100644
--- a/beetsplug/tidal/__init__.py
+++ b/beetsplug/tidal/__init__.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import itertools
-import os
import re
import time
from functools import cached_property
@@ -20,6 +19,7 @@
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Sequence
+ from pathlib import Path
from beets.autotag import Info
from beets.importer import ImportSession
@@ -93,15 +93,16 @@ class TidalPlugin
def api(self) -> TidalAPI:
return TidalAPI(
client_id=self.config["client_id"].as_str(),
- token_path=self._tokenfile(),
+ token_path=self.tokenfile,
)
- def _tokenfile(self) -> str:
- """Return the configured path to the token file in the app directory."""
- return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
+ @cached_property
+ def tokenfile(self) -> Path:
+ """Get the path to the JSON file for storing the OAuth token."""
+ return self.config["tokenfile"].get(confuse.Path(in_app_dir=True))
def require_authentication(self, session: ImportSession) -> None:
- if not os.path.isfile(self._tokenfile()):
+ if not self.tokenfile.is_file():
raise UserError(
"Please login to TIDAL"
" using `beet tidal --auth` or disable tidal plugin"I would suggest to create
All methods only work with |
Per review feedback on beetbox#6984, token-file writes are moving to a pathlib-based TokenFileMixin.write_tokenfile() instead of this standalone os.open()-based helper, which is now unused. Signed-off-by: Shivam <shivamssing25@gmail.com>
Adds TokenFileMixin (tokenfile property + write_tokenfile(), which chmods the file to 0600 after writing) for plugins that cache a secret in a JSON file, per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
Per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
Per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
Per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
Per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
Needed since TidalPlugin.api now passes a pathlib.Path for token_path (via TidalPlugin.tokenfile), per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
Per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
Superseded by TokenFileMixin.write_tokenfile(). Per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
Add tests for TokenFileMockPlugin and tokenfile handling. Per review feedback on beetbox#6984. Signed-off-by: Shivam <shivamssing25@gmail.com>
`ruff format --check` flagged this line as needing to be wrapped. Applied `ruff format`. Signed-off-by: Shivam <shivamssing25@gmail.com>
|
Thanks for the detailed suggestion! I've reworked this to use a shared |
semohr
left a comment
There was a problem hiding this comment.
I don't think we should abstract token loading as currently proposed , it adds a lot of abstraction and indirection for something that's basically one line per plugin (Current diff is +139. -52).
Token loading/saving should generally live with the session rather than the plugin imo: the session is what needs to reads and writes the file. The current TokenFileMixin is a bit of a leaky abstraction in that regard (see TidalPlugin and the upcoming PlexUpdate rewrite). Coupling the config-side tokenfile path with the write mixes a config concern with an IO/permissions concern that belongs where the file is actually written.
Could we just land the minimal fix here and leave the mixin for a separate discussion?
|
@semohr maybe we should tackle this one ourselves once we've decided how do we want to do it? |
Description
Fixes #6984.
Beets caches OAuth tokens/secrets on disk for the discogs, spotify,
beatport, and tidal plugins (
_tokenfile()/token_path, holdingthings like Discogs/Beatport tokens+secrets, a Spotify access token,
and a Tidal session token). All four call sites wrote these files with
a plain
open(path, "w"), so the actual on-disk permissions dependedentirely on the user's umask -- on a permissive umask the token file
ends up group- or world-readable.
This adds
beets.util.open_secure(), which opens a file for writingwith permissions restricted to the owner (0600):
os.openwith an explicit mode), rather than created with the default mode and then chmod'd afterward, which would leave a brief window where the file is more permissive than intended.All four plugins now use
open_secure()(orutil.open_secure()) in place of the bareopen(..., "w")call when writing their token file. The read side (loading an existing token) is unchanged.To Do
docs/changelog.rst.)OpenSecureTestintest/test_util.py, covering: new-file creation gets 0600; an existing file with looser permissions gets tightened to 0600 on write; and that a write still correctly overwrites previous contents. Full test suite passes locally:test_util.pyplus the discogs/spotify/beatport/tidal plugin test files, 215 passed / 3 skipped.)