Skip to content

Store cached OAuth token files with 0600 permissions - #6987

Open
kshivam4781 wants to merge 18 commits into
beetbox:masterfrom
kshivam4781:fix-tokenfile-permissions
Open

Store cached OAuth token files with 0600 permissions#6987
kshivam4781 wants to merge 18 commits into
beetbox:masterfrom
kshivam4781:fix-tokenfile-permissions

Conversation

@kshivam4781

Copy link
Copy Markdown

Description

Fixes #6984.

Beets caches OAuth tokens/secrets on disk for the discogs, spotify,
beatport, and tidal plugins (_tokenfile() / token_path, holding
things 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 depended
entirely 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 writing
with permissions restricted to the owner (0600):

  • New files are created atomically with mode 0600 (via os.open with 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.
  • If the file already exists -- e.g. a token file written before this change, with broader permissions -- its permissions are also tightened to 0600 the next time it's opened for writing.

All four plugins now use open_secure() (or util.open_secure()) in place of the bare open(..., "w") call when writing their token file. The read side (loading an existing token) is unchanged.

To Do

  • Documentation. (N/A -- no command-line flag or user-facing config change; this only affects how an existing token-file feature writes its file to disk.)
  • Changelog. (Added to docs/changelog.rst.)
  • Tests. (Added OpenSecureTest in test/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.py plus the discogs/spotify/beatport/tidal plugin test files, 215 passed / 3 skipped.)

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>
@kshivam4781
kshivam4781 requested review from a team and semohr as code owners September 3, 2026 18:46
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.66667% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.87%. Comparing base (8694a03) to head (8b38944).
⚠️ Report is 4 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beetsplug/tidal/session.py 0.00% 3 Missing ⚠️
beetsplug/discogs/__init__.py 60.00% 2 Missing ⚠️
beetsplug/beatport.py 75.00% 1 Missing ⚠️
beetsplug/tidal/__init__.py 66.66% 1 Missing ⚠️
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     
Files with missing lines Coverage Δ
beets/metadata_plugins.py 91.57% <100.00%> (+0.55%) ⬆️
beetsplug/spotify.py 63.29% <100.00%> (-0.46%) ⬇️
beetsplug/tidal/api.py 33.73% <100.00%> (ø)
beetsplug/beatport.py 50.21% <75.00%> (-0.21%) ⬇️
beetsplug/tidal/__init__.py 89.34% <66.66%> (-0.18%) ⬇️
beetsplug/discogs/__init__.py 69.53% <60.00%> (+0.05%) ⬆️
beetsplug/tidal/session.py 40.81% <0.00%> (+0.81%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@snejus

snejus commented Sep 3, 2026

Copy link
Copy Markdown
Member

I have refactored this in a separate branch where I migrate the codebase to pathlib. Would you mind refactoring this like below?

 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 TokenFileMixin class next to MetadataSourcePlugin to centralise this behaviour:

  1. Adds default "tokenfile" configuration under __init__
  2. Exposes tokenfile(self) -> Path @cached_property
  3. Exposes write_tokenfile(self, data: JSONDict) -> None method which writes the tokenfile. This includes path.chmod(...) to restrict the permissions.

All methods only work with pathlib.Path and use Path.read_text and Path.write_text methods.

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>
@kshivam4781

Copy link
Copy Markdown
Author

Thanks for the detailed suggestion! I've reworked this to use a shared TokenFileMixin (in beets/metadata_plugins.py) as you outlined, with a tokenfile property and a write_tokenfile() method that handles the write + 0600 chmod. All four plugins (discogs, spotify, beatport, tidal) now inherit it, open_secure() is removed, and tests are updated accordingly. CI is green. Let me know if you'd like anything else adjusted.

@semohr semohr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@snejus

snejus commented Sep 4, 2026

Copy link
Copy Markdown
Member

@semohr maybe we should tackle this one ourselves once we've decided how do we want to do it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Store token files with 0600 permissions

3 participants