Skip to content

Commit 6470850

Browse files
committed
cli+tests: improve PAT handling logic + error handling
from internal code review
1 parent b0e13f2 commit 6470850

9 files changed

Lines changed: 98 additions & 95 deletions

File tree

docs/user-guides/using-divbase-programmatically.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ See [Account Management — Personal Access Tokens](./account-management.md#pers
1212

1313
You can store/use a PAT in two ways
1414

15-
### Option 1 — Let `divbase-cli` handle storing the PAT (recommended)
15+
### (Recommended) — Let `divbase-cli` handle storing the PAT
1616

17-
After creating a PAT on the website, store it on your device by copy pasting the pre-filled commands shown on the website, it will look something like this:
17+
After creating a PAT on the DivBase website, store it on your device by copy pasting the pre-filled commands shown on the website, it will look something like this:
1818

1919
```bash
2020
# with an expiry date:
@@ -23,7 +23,7 @@ divbase-cli auth add-pat "my-pat-name" --expires UNIX_TIMESTAMP
2323
divbase-cli auth add-pat "my-pat-name"
2424
```
2525

26-
You will be prompted to paste the token value. It is then stored securely in your OS keyring (or in a restricted file if no keyring is available) and used automatically on every subsequent `divbase-cli` command.
26+
You will be prompted to paste the token value. It will then be stored securely in your OS keyring (or in a restricted file if no keyring is available) and used automatically on every subsequent `divbase-cli` command.
2727

2828
```bash
2929
divbase-cli auth pat-info # show name and expiry of the stored PAT
@@ -33,7 +33,7 @@ divbase-cli auth rm-pat # remove the stored PAT from this device
3333
!!! info "This strategy works on HPC clusters"
3434
On the login node with divbase-cli [installed as we recommend](./installation.md), store the PAT as described above. The token will be available to all your jobs running on the cluster, without needing to worry about setting any environment variables in your job scripts.
3535

36-
### Option 2 — Environment variable
36+
### (Not recommended alternative) — Store the PAT in an environment variable
3737

3838
Set `DIVBASE_API_PAT` in your shell or job script:
3939

@@ -44,8 +44,7 @@ divbase-cli files ls
4444

4545
You may prefer this if you want explicit per-job control of which token is used.
4646

47-
1. Store it securely on your device using `divbase-cli auth add-pat` (recommended and works on HPC clusters!)
48-
2. Set it as an environment variable `DIVBASE_API_PAT` (can give you more control over which token is used when)
47+
---
4948

5049
!!! question "What if I have both an active login session and a personal access token set?"
5150
`divbase-cli` follows this priority order:

packages/divbase-cli/src/divbase_cli/cli_commands/auth_cli.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import logging
6+
import time
67
from datetime import datetime
78

89
import typer
@@ -14,6 +15,7 @@
1415
from divbase_cli.config_resolver import resolve_url_for_non_project_specific_commands
1516
from divbase_cli.services.announcements import get_and_display_announcements
1617
from divbase_cli.user_auth import (
18+
PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE,
1719
PATData,
1820
check_existing_session,
1921
delete_stored_pat,
@@ -92,6 +94,7 @@ def add_pat(
9294
"--expires",
9395
"-e",
9496
help="When the personal access token (PAT) expires (if it does), as a unix timestamp.",
97+
min=1,
9598
),
9699
overwrite_existing: bool = typer.Option(
97100
False, "--overwrite-existing", "-o", help="Overwrite the existing stored PAT if one already exists."
@@ -114,7 +117,7 @@ def add_pat(
114117
)
115118
raise typer.Exit(code=1)
116119

117-
if expires_unix_timestamp and expires_unix_timestamp < int(datetime.now().timestamp()):
120+
if expires_unix_timestamp and expires_unix_timestamp < time.time():
118121
print("The expiry time you entered is in the past. Please enter a valid expiry time for the PAT.")
119122
raise typer.Exit(code=1)
120123

@@ -156,12 +159,13 @@ def pat_info():
156159
print("No personal access token stored on this device.")
157160
return
158161

159-
if pat_data.pat_expires_at and pat_data.pat_expires_at <= datetime.now().timestamp():
160-
print("[red]Warning: this PAT has expired.[/red]")
161-
162162
print(f"Name: {pat_data.name}")
163163
print(f"Expires: {pat_data.pat_expiry_formatted()}")
164164

165+
if pat_data.is_pat_expired():
166+
print("[red bold]Warning [/red bold]")
167+
print(PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE)
168+
165169

166170
@auth_app.command("whoami")
167171
def whoami():

packages/divbase-cli/src/divbase_cli/config_resolver.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from divbase_cli.cli_config import cli_settings
1212
from divbase_cli.cli_exceptions import AuthenticationError, ProjectNameNotSpecifiedError
13-
from divbase_cli.user_auth import load_stored_user_pat
13+
from divbase_cli.user_auth import get_pat_for_authentication
1414
from divbase_cli.user_config import ProjectConfig, load_user_config
1515

1616

@@ -28,7 +28,7 @@ def ensure_logged_in(desired_url: str | None = None) -> str:
2828
)
2929
return config.logged_in_url
3030

31-
if cli_settings.DIVBASE_API_PAT or load_stored_user_pat():
31+
if get_pat_for_authentication():
3232
return desired_url or cli_settings.DIVBASE_API_URL
3333

3434
raise AuthenticationError("You are not logged in. Please log in with 'divbase-cli auth login [EMAIL]'.")
@@ -48,8 +48,7 @@ def resolve_url_for_non_project_specific_commands() -> str:
4848
if config.logged_in_url:
4949
return config.logged_in_url
5050

51-
# No active session — fall back to PAT if available.
52-
if cli_settings.DIVBASE_API_PAT or load_stored_user_pat():
51+
if get_pat_for_authentication():
5352
return cli_settings.DIVBASE_API_URL
5453

5554
raise AuthenticationError("You are not logged in. Please log in with 'divbase-cli auth login [EMAIL]'.")

packages/divbase-cli/src/divbase_cli/user_auth.py

Lines changed: 54 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from datetime import datetime
2222
from json import JSONDecodeError
2323
from pathlib import Path
24+
from typing import Any
2425

2526
import httpx
2627
import keyring
@@ -38,6 +39,12 @@
3839
from divbase_lib.divbase_constants import CLI_VERSION_HEADER_KEY
3940

4041
LOGIN_AGAIN_MESSAGE = "Your session has expired. Please log in again with 'divbase-cli auth login [EMAIL]'."
42+
PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE = (
43+
"Your Personal Access Token (PAT) has expired. \n"
44+
"Please create a new one and add it with 'divbase-cli auth add-pat'. \n"
45+
"See https://scilifelabdatacentre.github.io/divbase/user-guides/account-management/#personal-access-tokens for more details."
46+
)
47+
4148

4249
logger = logging.getLogger(__name__)
4350

@@ -59,25 +66,11 @@ def dump_tokens(self, fallback_output_path: Path = cli_settings.TOKENS_FALLBACK_
5966
"access_token_expires_at": self.access_token_expires_at,
6067
"refresh_token_expires_at": self.refresh_token_expires_at,
6168
}
62-
try:
63-
keyring.set_password(
64-
service_name=cli_settings.KEYRING_SERVICE,
65-
username=cli_settings.KEYRING_TOKENS_USERNAME,
66-
password=json.dumps(token_dict),
67-
)
68-
fallback_output_path.unlink(missing_ok=True)
69-
logger.debug("JWTs stored in device keyring successfully.")
70-
return
71-
except KeyringError as e:
72-
logger.debug(f"Keyring JWT storage failed with error: {e}\n falling back to file storage.")
73-
74-
fallback_output_path.parent.mkdir(parents=True, exist_ok=True)
75-
# Create file with 0600 permissions (user read/write only).
76-
# Windows doesn't support 0600 permissions, but Windows can use keyring, so should never reach this fallback.
77-
# Even if a Windows OS can't use keyring the fallback will still work, the 0600 mode will just be ignored.
78-
fd = os.open(path=fallback_output_path, flags=os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode=0o600)
79-
with os.fdopen(fd, "w") as file:
80-
yaml.safe_dump(token_dict, file, sort_keys=False)
69+
_dump_to_keyring_or_file(
70+
data=token_dict,
71+
keyring_user_name=cli_settings.KEYRING_TOKENS_USERNAME,
72+
fallback_file_path=fallback_output_path,
73+
)
8174

8275
def is_access_token_expired(self) -> bool:
8376
"""Check if the access token is expired"""
@@ -112,25 +105,16 @@ def dump_pat_data(self, fallback_output_path: Path = cli_settings.PATS_FALLBACK_
112105
"pat_expires_at": self.pat_expires_at,
113106
"pat": self.pat.get_secret_value(),
114107
}
115-
try:
116-
keyring.set_password(
117-
service_name=cli_settings.KEYRING_SERVICE,
118-
username=cli_settings.KEYRING_PATS_USERNAME,
119-
password=json.dumps(token_dict),
120-
)
121-
fallback_output_path.unlink(missing_ok=True)
122-
logger.debug("PATs stored in device keyring successfully.")
123-
return
124-
except KeyringError as e:
125-
logger.debug(f"Keyring PAT storage failed with error: {e}\n falling back to file storage.")
126-
127-
fallback_output_path.parent.mkdir(parents=True, exist_ok=True)
128-
# Create file with 0600 permissions (user read/write only).
129-
# Windows doesn't support 0600 permissions, but Windows can use keyring, so should never reach this fallback.
130-
# Even if a Windows OS can't use keyring the fallback will still work, the 0600 mode will just be ignored.
131-
fd = os.open(path=fallback_output_path, flags=os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode=0o600)
132-
with os.fdopen(fd, "w") as file:
133-
yaml.safe_dump(token_dict, file, sort_keys=False)
108+
_dump_to_keyring_or_file(
109+
data=token_dict,
110+
keyring_user_name=cli_settings.KEYRING_PATS_USERNAME,
111+
fallback_file_path=fallback_output_path,
112+
)
113+
114+
def is_pat_expired(self) -> bool:
115+
"""Check if the PAT is expired"""
116+
cutoff = time.time() + 10 # 10 second buffer to account for clock skew or time delay
117+
return self.pat_expires_at is not None and self.pat_expires_at <= cutoff
134118

135119
def pat_expiry_formatted(self) -> str:
136120
"""Returns the token's expiry time in a human readable format in user's current timezone."""
@@ -314,7 +298,7 @@ def load_user_tokens(token_path: Path = cli_settings.TOKENS_FALLBACK_PATH) -> To
314298
access_token_expires_at=token_dict["access_token_expires_at"],
315299
refresh_token_expires_at=token_dict["refresh_token_expires_at"],
316300
)
317-
except KeyError as e:
301+
except (KeyError, TypeError) as e:
318302
logger.debug(f"User tokens file at {token_path} appears to be malformed: {e}")
319303
return None
320304

@@ -360,7 +344,7 @@ def load_stored_user_pat(pat_fallback_path: Path = cli_settings.PATS_FALLBACK_PA
360344
pat=SecretStr(pat_dict["pat"]),
361345
pat_expires_at=pat_dict["pat_expires_at"],
362346
)
363-
except KeyError as e:
347+
except (KeyError, TypeError) as e:
364348
logger.debug(f"User PAT file at {pat_fallback_path} appears to be malformed: {e}")
365349
return None
366350

@@ -386,12 +370,8 @@ def get_pat_for_authentication() -> SecretStr | None:
386370
if not pat_data:
387371
return None
388372

389-
if pat_data.pat_expires_at and pat_data.pat_expires_at <= time.time():
390-
raise AuthenticationError(
391-
"Your Personal Access Token (PAT) has expired. \n"
392-
"Please create a new one and add it with 'divbase-cli auth add-pat'. \n"
393-
"See https://scilifelabdatacentre.github.io/divbase/user-guides/account-management/#personal-access-tokens for more details."
394-
)
373+
if pat_data.is_pat_expired():
374+
raise AuthenticationError(PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE)
395375

396376
return pat_data.pat
397377

@@ -515,3 +495,31 @@ def _refresh_access_token(token_data: TokenData, divbase_base_url: str) -> Token
515495
)
516496
new_token_data.dump_tokens()
517497
return new_token_data
498+
499+
500+
def _dump_to_keyring_or_file(data: dict[Any, Any], keyring_user_name: str, fallback_file_path: Path) -> None:
501+
"""
502+
Helper fn to dump a dict to user's OS keyring.
503+
504+
If dump fails, will fallback to a local file and set permissions to 0600 (user read/write only).
505+
Used to store both JWTs and PATs.
506+
"""
507+
try:
508+
keyring.set_password(
509+
service_name=cli_settings.KEYRING_SERVICE,
510+
username=keyring_user_name,
511+
password=json.dumps(data),
512+
)
513+
fallback_file_path.unlink(missing_ok=True)
514+
logger.debug(f"Data stored in user device keyring under username: {keyring_user_name}")
515+
return
516+
except KeyringError as e:
517+
logger.debug(f"Keyring storage failed with error: {e}\n falling back to file storage.")
518+
519+
fallback_file_path.parent.mkdir(parents=True, exist_ok=True)
520+
# Create file with 0600 permissions (user read/write only).
521+
# Windows doesn't support 0600 permissions, but Windows can use keyring, so should never reach this fallback.
522+
# Even if a Windows OS can't use keyring the fallback will still work, the 0600 mode will just be ignored.
523+
fd = os.open(path=fallback_file_path, flags=os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode=0o600)
524+
with os.fdopen(fd, "w") as file:
525+
yaml.safe_dump(data, file, sort_keys=False)

tests/e2e_integration/cli_commands/test_auth_cli.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from divbase_cli.cli_exceptions import AuthenticationError, DivBaseAPIConnectionError, DivBaseAPIError
1717
from divbase_cli.divbase_cli import app
1818
from divbase_cli.user_auth import LOGIN_AGAIN_MESSAGE
19+
from divbase_lib.divbase_constants import PAT_TOKEN_PREFIX
1920

2021
runner = CliRunner()
2122

@@ -279,15 +280,15 @@ def test_jwt_session_takes_priority_over_pat(disable_keyring_backend, monkeypatc
279280
"""
280281
log_in_as_user()
281282

282-
monkeypatch.setattr(cli_settings, "DIVBASE_API_PAT", SecretStr("divbase_pat_fake_not_a_real_token"))
283+
monkeypatch.setattr(cli_settings, "DIVBASE_API_PAT", SecretStr(f"{PAT_TOKEN_PREFIX}_fake_not_a_real_token"))
283284

284285
result = runner.invoke(app=app, args="auth whoami")
285286
assert result.exit_code == 0
286287
assert USER_EMAIL in result.stdout
287288

288289

289290
_FAKE_PAT_NAME = "work-laptop"
290-
_FAKE_PAT = "divbase_pat_fakefakefakefakefakefakefake"
291+
_FAKE_PAT = f"{PAT_TOKEN_PREFIX}_fakefakefakefakefakefakefake"
291292

292293

293294
def run_add_pat_cmd(

tests/e2e_integration/playwright/test_personal_access_tokens.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def test_new_pat_form_renders(logged_in_edit_user_pat_page: Page):
8686
def test_create_full_access_pat_shows_token(logged_in_edit_user_pat_page: Page):
8787
"""
8888
Creating a PAT with no scope restrictions shows the 'Token generated' page,
89-
the copy-now warning, and the raw token (which must start with 'divbase_pat_').
89+
the copy-now warning, and the raw token (which must start with PAT_TOKEN_PREFIX set in divbase-lib's constants module).
9090
"""
9191
fill_and_submit_new_pat_form(logged_in_edit_user_pat_page, name="my-hpc-token")
9292

tests/unit/divbase_api/test_security.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
verify_password,
1818
verify_token,
1919
)
20+
from divbase_lib.divbase_constants import PAT_TOKEN_PREFIX
2021

2122
USER_ID = 1
2223

@@ -121,7 +122,7 @@ def test_password_hashing():
121122

122123
def test_personal_access_token_hashing():
123124
"""Test that hashing a personal access token produces a consistent hash."""
124-
raw_token = SecretStr("divbase_pat_abc123")
125+
raw_token = SecretStr(f"{PAT_TOKEN_PREFIX}_abc123")
125126
hashed_token_1 = hash_personal_access_token(raw_token)
126127
hashed_token_2 = hash_personal_access_token(raw_token)
127128
assert hashed_token_1 == hashed_token_2

0 commit comments

Comments
 (0)