insightfactory-cli 1.3.0.dev37__tar.gz → 1.3.0.dev39__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.github/workflows/claude.yml +8 -6
  2. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/CHANGELOG.md +26 -0
  3. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/CLAUDE.md +3 -2
  4. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/PKG-INFO +24 -9
  5. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/README.md +23 -8
  6. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/pyproject.toml +1 -1
  7. insightfactory_cli-1.3.0.dev39/src/if_cli/cache.py +318 -0
  8. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/logout.py +3 -4
  9. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/profiles.py +3 -3
  10. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/set_token.py +1 -1
  11. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/oauth.py +13 -14
  12. insightfactory_cli-1.3.0.dev39/tests/cache_writer.py +16 -0
  13. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_attribution.py +1 -1
  14. insightfactory_cli-1.3.0.dev39/tests/test_cache.py +290 -0
  15. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_cli.py +2 -2
  16. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_mcp_command.py +6 -2
  17. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_oauth_flow.py +113 -21
  18. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_oauth_force_refresh.py +3 -3
  19. insightfactory_cli-1.3.0.dev39/tests/test_profile_tokens.py +170 -0
  20. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_upstream.py +26 -2
  21. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_set_token.py +1 -3
  22. insightfactory_cli-1.3.0.dev37/src/if_cli/cache.py +0 -206
  23. insightfactory_cli-1.3.0.dev37/tests/cache_writer.py +0 -14
  24. insightfactory_cli-1.3.0.dev37/tests/test_cache.py +0 -146
  25. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.github/workflows/ci.yml +0 -0
  26. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.github/workflows/release.yml +0 -0
  27. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.gitignore +0 -0
  28. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.python-version +0 -0
  29. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/AGENTS.md +0 -0
  30. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/LICENSE +0 -0
  31. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/__init__.py +0 -0
  32. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/__main__.py +0 -0
  33. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/assets/__init__.py +0 -0
  34. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
  35. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/attribution.py +0 -0
  36. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/callback_page.py +0 -0
  37. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/cli.py +0 -0
  38. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/colour.py +0 -0
  39. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/__init__.py +0 -0
  40. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/api.py +0 -0
  41. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/config.py +0 -0
  42. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/login.py +0 -0
  43. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/mcp.py +0 -0
  44. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/token.py +0 -0
  45. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/config.py +0 -0
  46. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/constants.py +0 -0
  47. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/http.py +0 -0
  48. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/main.py +0 -0
  49. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/__init__.py +0 -0
  50. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/catalog.py +0 -0
  51. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/content.py +0 -0
  52. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/log.py +0 -0
  53. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/policy.py +0 -0
  54. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/server.py +0 -0
  55. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/upstream.py +0 -0
  56. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/runtime.py +0 -0
  57. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/__init__.py +0 -0
  58. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/conftest.py +0 -0
  59. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/helpers.py +0 -0
  60. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/servers.py +0 -0
  61. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_api.py +0 -0
  62. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_api_command.py +0 -0
  63. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_config.py +0 -0
  64. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_config_command.py +0 -0
  65. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_login.py +0 -0
  66. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_oauth.py +0 -0
  67. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_profiles.py +0 -0
  68. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_programmatic_api.py +0 -0
  69. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_release_tools.py +0 -0
  70. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_catalog.py +0 -0
  71. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_content.py +0 -0
  72. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_log.py +0 -0
  73. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_policy.py +0 -0
  74. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_resources.py +0 -0
  75. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_server.py +0 -0
  76. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_runtime.py +0 -0
  77. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_token.py +0 -0
  78. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tools/check_release.py +0 -0
  79. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tools/release_notes.py +0 -0
  80. {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/uv.lock +0 -0
@@ -62,9 +62,10 @@ jobs:
62
62
  - Profiles and tokens must be written only through the atomic private-file helpers
63
63
  (`write_private_file` in config.py): `0o600` files, `0o700` directories, atomic replace
64
64
  via temp file.
65
- - On-disk profile and token-cache paths, JSON shapes, and file permissions must stay
66
- byte-compatible with the Node `@insightfactory-ai/if-cli` so users can switch
67
- implementations without re-authenticating.
65
+ - The Node CLI is retired. Profile sections keep the Node layout (`~/.insightfactory`,
66
+ `INSIGHTFACTORY_CONFIG_DIR` override). `token-cache.json` is version 2: keyed by
67
+ profile name, with the host in each entry. Older `if-cli` releases and the Node CLI
68
+ cannot read it, and that is accepted. Reading a version 1 cache must keep working.
68
69
 
69
70
  Programmatic API (breaking-change surface):
70
71
  - These four symbols are a supported in-process surface documented in README.md:
@@ -125,9 +126,10 @@ jobs:
125
126
  - Profiles and tokens must be written only through the atomic private-file helpers
126
127
  (`write_private_file` in config.py): `0o600` files, `0o700` directories, atomic replace
127
128
  via temp file.
128
- - On-disk profile and token-cache paths, JSON shapes, and file permissions must stay
129
- byte-compatible with the Node `@insightfactory-ai/if-cli` so users can switch
130
- implementations without re-authenticating.
129
+ - The Node CLI is retired. Profile sections keep the Node layout (`~/.insightfactory`,
130
+ `INSIGHTFACTORY_CONFIG_DIR` override). `token-cache.json` is version 2: keyed by
131
+ profile name, with the host in each entry. Older `if-cli` releases and the Node CLI
132
+ cannot read it, and that is accepted. Reading a version 1 cache must keep working.
131
133
 
132
134
  Programmatic API (breaking-change surface):
133
135
  - These four symbols are a supported in-process surface documented in README.md:
@@ -18,6 +18,32 @@ last one has been released, so a cycle's PRs share a section.
18
18
  - Internal only: a PR comment naming `@agent-review` runs the shared Claude and
19
19
  Codex review, which can approve a PR that a person from the required team
20
20
  still approves. The CLI itself is unchanged.
21
+ - `logout` names the profile as well as the host in its output, for example
22
+ `✓ cleared cached token for profile 'dev' (https://factory.example)`, because
23
+ two profiles can now share a host.
24
+
25
+ ### Fixed
26
+
27
+ - The token cache is keyed by profile, not by host. Before, two profiles on the
28
+ same factory host shared one token, so signing in as a second account on one
29
+ profile silently replaced the other profile's token, and that profile then
30
+ acted as the second account. Each profile now keeps its own token through
31
+ `login`, refresh, `token`, `api`, `mcp`, `profiles`, `set-token` and `logout`.
32
+ Each entry records the host that issued it, and a profile whose `host` changes
33
+ stops using it, so the token is not sent to the new origin.
34
+ - `token-cache.json` moves to version 2. The CLI reads a version 1 cache and
35
+ gives each host's token to every profile on that host, so upgrading needs no
36
+ new login. The first write saves version 2. Older `if-cli` releases cannot
37
+ read version 2 and stop on every command, `login` included. To downgrade,
38
+ move `token-cache.json` aside, then sign in to each profile again.
39
+ - A version 1 cache cannot say which account its host entry holds. If two
40
+ profiles on one host signed in as different accounts before this release, both
41
+ start out holding whichever account signed in last. Run
42
+ `if-cli login -p <profile>` once for each such profile to give it its own
43
+ account.
44
+ - Internal only: the test that `mcp` answers `initialize` before the catalogue
45
+ build finishes allows 3 seconds instead of 1, because starting the subprocess
46
+ on a CI runner took just over a second and failed the test at random.
21
47
 
22
48
  ## 1.2.0
23
49
 
@@ -22,8 +22,9 @@ uv run ty check
22
22
  - OAuth uses authorization-code flow with PKCE and a loopback-only callback.
23
23
  - Store profiles and tokens through the atomic private-file helpers.
24
24
  - The Node CLI is retired; `[factory <name>]` sections are Python-only. Profile
25
- sections and the token store keep the Node layout (`~/.insightfactory`,
26
- `INSIGHTFACTORY_CONFIG_DIR` override).
25
+ sections keep the Node layout (`~/.insightfactory`, `INSIGHTFACTORY_CONFIG_DIR`
26
+ override). `token-cache.json` is version 2: keyed by profile name, with the
27
+ host in each entry. An older `if-cli` cannot read it.
27
28
  - Do not commit credentials, access tokens, refresh tokens, or client secrets.
28
29
  - Test fixtures use only neutral hostnames (`factory.example`).
29
30
  - Do not publish to PyPI from a developer machine. Publishing happens in CI: the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.3.0.dev37
3
+ Version: 1.3.0.dev39
4
4
  Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
5
  Project-URL: Homepage, https://insightfactory.ai
6
6
  Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
@@ -27,9 +27,9 @@ Description-Content-Type: text/markdown
27
27
  Profile-based authentication CLI for the InsightFactory Interfaces API. It
28
28
  installs the `if-cli` command, which logs in through the browser with OAuth
29
29
  authorization code and PKCE, refreshes tokens, and makes authenticated API
30
- calls. Profiles live in `~/.insightfactory` in the same layout the Node
31
- `@insightfactory-ai/if-cli` uses, so both implementations can share one config
32
- directory.
30
+ calls. Profiles live in `~/.insightfactory` in the same layout the retired Node
31
+ `@insightfactory-ai/if-cli` used. The token cache uses its own format, described
32
+ under "How authentication works".
33
33
 
34
34
  This is the production Python CLI, published to public PyPI. It needs Python
35
35
  3.10 or newer.
@@ -229,8 +229,20 @@ rejected.
229
229
  - Profiles live in `~/.insightfactory/config`, one per customer and environment.
230
230
  - The CLI manages the profile file and regenerates it on updates. Comments and
231
231
  empty sections are not preserved.
232
- - Tokens are cached by host in `~/.insightfactory/token-cache.json` with file
233
- mode `0600`.
232
+ - Tokens are cached per profile in `~/.insightfactory/token-cache.json` with
233
+ file mode `0600`. Two profiles on the same host can sign in as different
234
+ accounts, and each keeps its own token. Each entry records the host that
235
+ issued it, and a profile uses it only while its `host` still matches, so a
236
+ token never goes to another origin after a host change.
237
+ - `if-cli` 1.3.0 and later write version 2 of the cache, keyed by profile
238
+ name. They read the older version 1 cache, keyed by host, and give its token
239
+ to every profile on that host, so an upgrade needs no new login. Older
240
+ `if-cli` releases and the Node CLI cannot read version 2 and stop on every
241
+ command, `login` included. To downgrade, move `token-cache.json` aside, then
242
+ sign in to each profile again.
243
+ - A version 1 cache holds one account per host. If two profiles on one host
244
+ signed in as different accounts before 1.3.0, both start out as whichever
245
+ account signed in last. Run `if-cli login -p <profile>` once for each of them.
234
246
  - The configuration directory is `0700`. Configuration and cache updates are
235
247
  atomic.
236
248
  - Remote factory, authorization, and token endpoints must use HTTPS. HTTP is
@@ -512,9 +524,12 @@ differ:
512
524
  - **`--version`** reads the installed package metadata from `1.0.0` onward
513
525
  rather than an npm `package.json`.
514
526
 
515
- On-disk profile and token-cache paths, JSON shapes, and file permissions stay
516
- compatible with the Node CLI, so you can switch implementations without
517
- re-authenticating.
527
+ On-disk profile and token-cache paths and file permissions match the Node CLI.
528
+ The token cache's JSON shape does not: `if-cli` keys it by profile (version 2),
529
+ which the Node CLI and older `if-cli` releases cannot read. The Python CLI
530
+ still reads a Node-written version 1 cache, so moving from Node to Python needs
531
+ no new login. To move back, move `token-cache.json` aside and sign in to each
532
+ profile again.
518
533
 
519
534
  ## Licence
520
535
 
@@ -3,9 +3,9 @@
3
3
  Profile-based authentication CLI for the InsightFactory Interfaces API. It
4
4
  installs the `if-cli` command, which logs in through the browser with OAuth
5
5
  authorization code and PKCE, refreshes tokens, and makes authenticated API
6
- calls. Profiles live in `~/.insightfactory` in the same layout the Node
7
- `@insightfactory-ai/if-cli` uses, so both implementations can share one config
8
- directory.
6
+ calls. Profiles live in `~/.insightfactory` in the same layout the retired Node
7
+ `@insightfactory-ai/if-cli` used. The token cache uses its own format, described
8
+ under "How authentication works".
9
9
 
10
10
  This is the production Python CLI, published to public PyPI. It needs Python
11
11
  3.10 or newer.
@@ -205,8 +205,20 @@ rejected.
205
205
  - Profiles live in `~/.insightfactory/config`, one per customer and environment.
206
206
  - The CLI manages the profile file and regenerates it on updates. Comments and
207
207
  empty sections are not preserved.
208
- - Tokens are cached by host in `~/.insightfactory/token-cache.json` with file
209
- mode `0600`.
208
+ - Tokens are cached per profile in `~/.insightfactory/token-cache.json` with
209
+ file mode `0600`. Two profiles on the same host can sign in as different
210
+ accounts, and each keeps its own token. Each entry records the host that
211
+ issued it, and a profile uses it only while its `host` still matches, so a
212
+ token never goes to another origin after a host change.
213
+ - `if-cli` 1.3.0 and later write version 2 of the cache, keyed by profile
214
+ name. They read the older version 1 cache, keyed by host, and give its token
215
+ to every profile on that host, so an upgrade needs no new login. Older
216
+ `if-cli` releases and the Node CLI cannot read version 2 and stop on every
217
+ command, `login` included. To downgrade, move `token-cache.json` aside, then
218
+ sign in to each profile again.
219
+ - A version 1 cache holds one account per host. If two profiles on one host
220
+ signed in as different accounts before 1.3.0, both start out as whichever
221
+ account signed in last. Run `if-cli login -p <profile>` once for each of them.
210
222
  - The configuration directory is `0700`. Configuration and cache updates are
211
223
  atomic.
212
224
  - Remote factory, authorization, and token endpoints must use HTTPS. HTTP is
@@ -488,9 +500,12 @@ differ:
488
500
  - **`--version`** reads the installed package metadata from `1.0.0` onward
489
501
  rather than an npm `package.json`.
490
502
 
491
- On-disk profile and token-cache paths, JSON shapes, and file permissions stay
492
- compatible with the Node CLI, so you can switch implementations without
493
- re-authenticating.
503
+ On-disk profile and token-cache paths and file permissions match the Node CLI.
504
+ The token cache's JSON shape does not: `if-cli` keys it by profile (version 2),
505
+ which the Node CLI and older `if-cli` releases cannot read. The Python CLI
506
+ still reads a Node-written version 1 cache, so moving from Node to Python needs
507
+ no new login. To move back, move `token-cache.json` aside and sign in to each
508
+ profile again.
494
509
 
495
510
  ## Licence
496
511
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.3.0.dev37"
3
+ version = "1.3.0.dev39"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import os
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import NoReturn, TypedDict, TypeGuard, TypeVar
10
+
11
+ from if_cli.config import cache_file, get_profile, is_factory_section, load_config, write_private_file
12
+ from if_cli.constants import EXPIRY_SLACK_SECONDS
13
+ from if_cli.runtime import die, is_record
14
+
15
+ CACHE_LOCK_STALE_MS = 30_000
16
+ CACHE_VERSION = 2
17
+ HOST_KEYED_CACHE_VERSION = 1
18
+
19
+
20
+ class CacheEntry(TypedDict, total=False):
21
+ host: str
22
+ grant: str
23
+ access_token: str
24
+ refresh_token: str
25
+ expires_at: float
26
+ token_endpoint: str
27
+ client_id: str
28
+
29
+
30
+ class TokenCache(TypedDict):
31
+ """Version 2 keys `tokens` by profile name. Each entry records the host it was
32
+ issued for, and is used only while the profile still points at that host."""
33
+
34
+ version: int
35
+ tokens: dict[str, CacheEntry]
36
+
37
+
38
+ class TokenOwner(TypedDict):
39
+ """The part of a `Profile` that owns a cache entry."""
40
+
41
+ name: str
42
+ host: str
43
+
44
+
45
+ def empty_cache() -> TokenCache:
46
+ return {"version": CACHE_VERSION, "tokens": {}}
47
+
48
+
49
+ def entry_for(cache: TokenCache, owner: TokenOwner) -> CacheEntry | None:
50
+ """The profile's entry, unless it was issued for a host the profile no longer
51
+ names: a bearer token never goes to an origin other than the one it came from."""
52
+ entry = cache["tokens"].get(owner["name"])
53
+ if entry is None or entry.get("host") != owner["host"]:
54
+ return None
55
+ return entry
56
+
57
+
58
+ def _siblings(cache: TokenCache, owner: TokenOwner, grant: str | None) -> list[str]:
59
+ """Other profiles on the same host that share the owner's `grant`.
60
+
61
+ Only a version 1 cache produces these: it held one entry per host, which the
62
+ upgrade copies to every profile on that host, marking the copies with one
63
+ `grant` id. They are one sign-in, so a rotation or rejection on one applies to
64
+ all of them; leaving a sibling on the rotated-out refresh token would replay it
65
+ on its next refresh. The id outlives a cleared refresh token, so a refresh that
66
+ lands after a peer's invalid_grant still reaches every sibling. A login or a
67
+ pasted token starts a fresh entry without it, which ends the sharing.
68
+ """
69
+ if not grant:
70
+ return []
71
+ return [
72
+ name
73
+ for name, entry in cache["tokens"].items()
74
+ if name != owner["name"] and entry.get("host") == owner["host"] and entry.get("grant") == grant
75
+ ]
76
+
77
+
78
+ def _positive_integer_env(name: str, fallback: int) -> int:
79
+ raw = os.environ.get(name)
80
+ if raw is None:
81
+ return fallback
82
+ try:
83
+ value = float(raw)
84
+ except (TypeError, ValueError):
85
+ return fallback
86
+ if math.isfinite(value) and value == int(value) and value > 0:
87
+ return int(value)
88
+ return fallback
89
+
90
+
91
+ def _cache_lock_file() -> str:
92
+ return f"{cache_file()}.lock"
93
+
94
+
95
+ def _cache_lock_attempts() -> int:
96
+ return _positive_integer_env("INSIGHTFACTORY_CACHE_LOCK_ATTEMPTS", 200)
97
+
98
+
99
+ def _cache_lock_retry_ms() -> int:
100
+ return _positive_integer_env("INSIGHTFACTORY_CACHE_LOCK_RETRY_MS", 25)
101
+
102
+
103
+ def is_fresh(entry: CacheEntry, now_seconds: float | None = None) -> bool:
104
+ if now_seconds is None:
105
+ now_seconds = time.time()
106
+ return entry["expires_at"] - EXPIRY_SLACK_SECONDS > now_seconds
107
+
108
+
109
+ def _is_cache_entry(value: object, *, with_host: bool) -> TypeGuard[CacheEntry]:
110
+ if not is_record(value):
111
+ return False
112
+ if with_host and not isinstance(value.get("host"), str):
113
+ return False
114
+ if "grant" in value and not isinstance(value.get("grant"), str):
115
+ return False
116
+ access_token = value.get("access_token")
117
+ expires_at = value.get("expires_at")
118
+ token_endpoint = value.get("token_endpoint")
119
+ refresh_token = value.get("refresh_token")
120
+ client_id = value.get("client_id")
121
+ return (
122
+ isinstance(access_token, str)
123
+ and isinstance(expires_at, (int, float))
124
+ and not isinstance(expires_at, bool)
125
+ and math.isfinite(float(expires_at))
126
+ and isinstance(token_endpoint, str)
127
+ and ("refresh_token" not in value or isinstance(refresh_token, str))
128
+ and ("client_id" not in value or isinstance(client_id, str))
129
+ )
130
+
131
+
132
+ def _invalid_structure() -> NoReturn:
133
+ die(f"token cache {cache_file()} has an invalid structure; move it aside and log in again")
134
+
135
+
136
+ def _parse_entries(tokens_value: dict[str, object], *, with_host: bool) -> dict[str, CacheEntry]:
137
+ tokens: dict[str, CacheEntry] = {}
138
+ for key, entry in tokens_value.items():
139
+ if not _is_cache_entry(entry, with_host=with_host):
140
+ die(f"token cache entry for {key} is invalid; move {cache_file()} aside and log in again")
141
+ tokens[key] = entry
142
+ return tokens
143
+
144
+
145
+ def _from_host_keyed(tokens_by_host: dict[str, CacheEntry]) -> dict[str, CacheEntry]:
146
+ """Give each configured profile the version 1 entry for its host.
147
+
148
+ A host entry with no profile left on that host is unreachable, so it is
149
+ dropped. A profile that does not resolve gets nothing, as it could not have
150
+ used a token before either.
151
+ """
152
+ config = load_config()
153
+ tokens: dict[str, CacheEntry] = {}
154
+ for section in config:
155
+ if is_factory_section(section):
156
+ continue
157
+ name = "DEFAULT" if section == "" else section
158
+ try:
159
+ host = get_profile(config, name)["host"]
160
+ except Exception:
161
+ # Not only CliError: a bad port raises ValueError or OverflowError. One
162
+ # broken profile must not stop every other profile's cache read.
163
+ continue
164
+ entry = tokens_by_host.get(host)
165
+ if entry is not None:
166
+ migrated = entry.copy()
167
+ migrated["host"] = host
168
+ tokens[name] = migrated
169
+ hosts = [entry["host"] for entry in tokens.values()]
170
+ for entry in tokens.values():
171
+ refresh_token = entry.get("refresh_token")
172
+ if refresh_token and hosts.count(entry["host"]) > 1:
173
+ # Derived, not random: every process reading the same version 1 file
174
+ # agrees on it until the first write saves version 2.
175
+ entry["grant"] = hashlib.sha256(f"{entry['host']}\n{refresh_token}".encode()).hexdigest()[:32]
176
+ return tokens
177
+
178
+
179
+ def _parse_cache(raw: str) -> TokenCache:
180
+ value: object = json.loads(raw)
181
+ if not is_record(value):
182
+ _invalid_structure()
183
+ tokens_value = value.get("tokens")
184
+ if not is_record(tokens_value):
185
+ _invalid_structure()
186
+ version = value.get("version")
187
+ if version == CACHE_VERSION:
188
+ return {"version": CACHE_VERSION, "tokens": _parse_entries(tokens_value, with_host=True)}
189
+ if version == HOST_KEYED_CACHE_VERSION:
190
+ tokens_by_host = _parse_entries(tokens_value, with_host=False)
191
+ return {"version": CACHE_VERSION, "tokens": _from_host_keyed(tokens_by_host)}
192
+ _invalid_structure()
193
+
194
+
195
+ def load_cache() -> TokenCache:
196
+ try:
197
+ with open(cache_file(), encoding="utf-8") as handle:
198
+ return _parse_cache(handle.read())
199
+ except FileNotFoundError:
200
+ return empty_cache()
201
+ except json.JSONDecodeError:
202
+ die(f"token cache {cache_file()} is not valid JSON; move it aside and log in again")
203
+
204
+
205
+ def _save_cache(cache: TokenCache) -> None:
206
+ write_private_file(cache_file(), f"{json.dumps(cache, indent=2)}\n")
207
+
208
+
209
+ T = TypeVar("T")
210
+
211
+
212
+ def with_cache_lock(operation: Callable[[], T]) -> T:
213
+ lock_file = _cache_lock_file()
214
+ os.makedirs(os.path.dirname(lock_file), mode=0o700, exist_ok=True)
215
+ attempts = _cache_lock_attempts()
216
+ retry_ms = _cache_lock_retry_ms()
217
+ for _attempt in range(attempts):
218
+ try:
219
+ descriptor = os.open(lock_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
220
+ except FileExistsError:
221
+ try:
222
+ mtime_ms = os.stat(lock_file).st_mtime * 1000
223
+ if time.time() * 1000 - mtime_ms > CACHE_LOCK_STALE_MS:
224
+ os.unlink(lock_file)
225
+ continue
226
+ except OSError:
227
+ pass
228
+ time.sleep(retry_ms / 1000)
229
+ continue
230
+ try:
231
+ return operation()
232
+ finally:
233
+ os.close(descriptor)
234
+ try:
235
+ os.unlink(lock_file)
236
+ except OSError:
237
+ pass
238
+ die(f"timed out waiting for token cache lock {lock_file}")
239
+
240
+
241
+ def clear_token(owner: TokenOwner) -> bool:
242
+ """Remove the profile's entry, whichever host it was issued for."""
243
+
244
+ def operation() -> bool:
245
+ cache = load_cache()
246
+ if owner["name"] not in cache["tokens"]:
247
+ return False
248
+ del cache["tokens"][owner["name"]]
249
+ _save_cache(cache)
250
+ return True
251
+
252
+ return with_cache_lock(operation)
253
+
254
+
255
+ def clear_refresh_token_if_matches(owner: TokenOwner, rejected_token: str) -> bool:
256
+ def operation() -> bool:
257
+ cache = load_cache()
258
+ entry = entry_for(cache, owner)
259
+ if not entry or entry.get("refresh_token") != rejected_token:
260
+ return False
261
+ for name in _siblings(cache, owner, entry.get("grant")):
262
+ if cache["tokens"][name].get("refresh_token") == rejected_token:
263
+ cache["tokens"][name].pop("refresh_token", None)
264
+ entry.pop("refresh_token", None)
265
+ _save_cache(cache)
266
+ return True
267
+
268
+ return with_cache_lock(operation)
269
+
270
+
271
+ def store_tokens(
272
+ owner: TokenOwner,
273
+ token_response: dict[str, object],
274
+ token_endpoint: str,
275
+ client_id: str | None = None,
276
+ *,
277
+ replace: bool = False,
278
+ ) -> CacheEntry:
279
+ """Write the profile's entry. `replace` starts it afresh (a login or a pasted
280
+ token); otherwise it updates the entry in place (a refresh)."""
281
+
282
+ def operation() -> CacheEntry:
283
+ cache = load_cache()
284
+ previous = None if replace else entry_for(cache, owner)
285
+ expires_in_raw = token_response.get("expires_in")
286
+ if (
287
+ isinstance(expires_in_raw, bool)
288
+ or not isinstance(expires_in_raw, (int, float))
289
+ or not math.isfinite(float(expires_in_raw))
290
+ ):
291
+ expires_in: int | float = 3600
292
+ else:
293
+ expires_in = expires_in_raw
294
+ entry: CacheEntry = previous.copy() if previous is not None else {}
295
+ access_token = token_response.get("access_token")
296
+ if not isinstance(access_token, str):
297
+ die("token response did not include a valid access token")
298
+ entry["host"] = owner["host"]
299
+ entry["access_token"] = access_token
300
+ entry["expires_at"] = int(math.floor(time.time()) + expires_in)
301
+ entry["token_endpoint"] = token_endpoint
302
+ if client_id:
303
+ entry["client_id"] = client_id
304
+ else:
305
+ entry.pop("client_id", None)
306
+ refresh_token = token_response.get("refresh_token")
307
+ if isinstance(refresh_token, str) and refresh_token:
308
+ entry["refresh_token"] = refresh_token
309
+ elif replace:
310
+ entry.pop("refresh_token", None)
311
+ if previous is not None:
312
+ for name in _siblings(cache, owner, previous.get("grant")):
313
+ cache["tokens"][name] = entry.copy()
314
+ cache["tokens"][owner["name"]] = entry
315
+ _save_cache(cache)
316
+ return entry
317
+
318
+ return with_cache_lock(operation)
@@ -14,7 +14,6 @@ LOGOUT_OPTIONS: Options = {
14
14
  def logout_command(argv: list[str]) -> None:
15
15
  values, _positionals = parse_args(argv, options=LOGOUT_OPTIONS)
16
16
  profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
17
- removed = clear_token(profile["host"])
18
- sys.stdout.write(
19
- f"✓ cleared cached token for {profile['host']}\n" if removed else f"no cached token for {profile['host']}\n"
20
- )
17
+ removed = clear_token(profile)
18
+ target = f"profile '{profile['name']}' ({profile['host']})"
19
+ sys.stdout.write(f"✓ cleared cached token for {target}\n" if removed else f"no cached token for {target}\n")
@@ -5,7 +5,7 @@ import sys
5
5
  import time
6
6
  from typing import Literal, TypedDict
7
7
 
8
- from if_cli.cache import TokenCache, is_fresh, load_cache
8
+ from if_cli.cache import TokenCache, empty_cache, entry_for, is_fresh, load_cache
9
9
  from if_cli.cli import Options, parse_args
10
10
  from if_cli.colour import Colour, colourise
11
11
  from if_cli.config import config_file, get_profile, is_factory_section, load_config
@@ -57,7 +57,7 @@ def summarize_profiles(
57
57
  )
58
58
  continue
59
59
 
60
- entry = cache["tokens"].get(profile["host"])
60
+ entry = entry_for(cache, profile)
61
61
  token: ProfileTokenSummary
62
62
  if cache_error:
63
63
  token = {"status": "unknown", "error": cache_error}
@@ -99,7 +99,7 @@ def profiles_command(argv: list[str] | None = None) -> None:
99
99
  sys.stdout.write(f"no profiles configured in {config_file()}\n")
100
100
  return
101
101
 
102
- cache: TokenCache = {"version": 1, "tokens": {}}
102
+ cache: TokenCache = empty_cache()
103
103
  cache_error: str | None = None
104
104
  try:
105
105
  cache = load_cache()
@@ -48,7 +48,7 @@ def set_token_command(argv: list[str]) -> None:
48
48
  if expiry is not None and expiry <= now:
49
49
  die("the provided access token is already expired")
50
50
  entry = store_tokens(
51
- profile["host"],
51
+ profile,
52
52
  {
53
53
  "access_token": token,
54
54
  "expires_in": 3600 if expiry is None else expiry - now,
@@ -14,6 +14,7 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
14
14
  from if_cli.cache import (
15
15
  CacheEntry,
16
16
  clear_refresh_token_if_matches,
17
+ entry_for,
17
18
  is_fresh,
18
19
  load_cache,
19
20
  store_tokens,
@@ -334,10 +335,10 @@ def login(profile: Profile, no_browser: bool, options: dict[str, str] | None = N
334
335
  "redirect_uri": redirect_uri,
335
336
  },
336
337
  )
337
- return store_tokens(profile["host"], tokens, metadata["token_endpoint"], client_id, replace=True)
338
+ return store_tokens(profile, tokens, metadata["token_endpoint"], client_id, replace=True)
338
339
 
339
340
 
340
- def _refresh(host: str, entry: CacheEntry, refresh_token: str, client_id: str) -> CacheEntry:
341
+ def _refresh(profile: Profile, entry: CacheEntry, refresh_token: str, client_id: str) -> CacheEntry:
341
342
  tokens = _token_request(
342
343
  entry["token_endpoint"],
343
344
  {
@@ -346,12 +347,12 @@ def _refresh(host: str, entry: CacheEntry, refresh_token: str, client_id: str) -
346
347
  "refresh_token": refresh_token,
347
348
  },
348
349
  )
349
- return store_tokens(host, tokens, entry["token_endpoint"], client_id)
350
+ return store_tokens(profile, tokens, entry["token_endpoint"], client_id)
350
351
 
351
352
 
352
- def _wait_for_fresh_token(host: str, *, exclude: str | None = None) -> str | None:
353
+ def _wait_for_fresh_token(profile: Profile, *, exclude: str | None = None) -> str | None:
353
354
  for attempt in range(RECOVERY_POLL_ATTEMPTS):
354
- entry = load_cache()["tokens"].get(host)
355
+ entry = entry_for(load_cache(), profile)
355
356
  if entry and is_fresh(entry) and entry["access_token"] != exclude:
356
357
  return entry["access_token"]
357
358
  if attempt + 1 < RECOVERY_POLL_ATTEMPTS:
@@ -360,7 +361,7 @@ def _wait_for_fresh_token(host: str, *, exclude: str | None = None) -> str | Non
360
361
 
361
362
 
362
363
  def get_valid_token(profile: Profile, *, force_refresh: bool = False) -> str:
363
- entry = load_cache()["tokens"].get(profile["host"])
364
+ entry = entry_for(load_cache(), profile)
364
365
  if not entry:
365
366
  die(f"no cached token for {profile['host']} — run: if-cli login -p {profile['name']}")
366
367
  if not force_refresh and is_fresh(entry):
@@ -374,32 +375,30 @@ def get_valid_token(profile: Profile, *, force_refresh: bool = False) -> str:
374
375
  if refresh_token:
375
376
  client_id = entry.get("client_id")
376
377
  if not client_id:
377
- clear_refresh_token_if_matches(profile["host"], refresh_token)
378
+ clear_refresh_token_if_matches(profile, refresh_token)
378
379
  die(
379
380
  f"could not refresh token for {profile['host']}: no client id cached — "
380
381
  f"run: if-cli login -p {profile['name']}"
381
382
  )
382
383
  try:
383
- return _refresh(profile["host"], entry, refresh_token, client_id)["access_token"]
384
+ return _refresh(profile, entry, refresh_token, client_id)["access_token"]
384
385
  except Exception as error:
385
386
  failure: BaseException = error
386
387
  if isinstance(error, TokenRequestError) and error.invalidates_refresh_token:
387
- cleared = clear_refresh_token_if_matches(profile["host"], refresh_token)
388
+ cleared = clear_refresh_token_if_matches(profile, refresh_token)
388
389
  if cleared:
389
390
  recovered_access_token = _wait_for_fresh_token(
390
- profile["host"], exclude=entry["access_token"] if force_refresh else None
391
+ profile, exclude=entry["access_token"] if force_refresh else None
391
392
  )
392
393
  if recovered_access_token:
393
394
  return recovered_access_token
394
395
  else:
395
- recovered = load_cache()["tokens"].get(profile["host"])
396
+ recovered = entry_for(load_cache(), profile)
396
397
  recovered_refresh = recovered.get("refresh_token") if recovered else None
397
398
  recovered_client_id = recovered.get("client_id") if recovered else None
398
399
  if recovered and recovered_refresh and recovered_refresh != refresh_token and recovered_client_id:
399
400
  try:
400
- return _refresh(profile["host"], recovered, recovered_refresh, recovered_client_id)[
401
- "access_token"
402
- ]
401
+ return _refresh(profile, recovered, recovered_refresh, recovered_client_id)["access_token"]
403
402
  except Exception as retry_error:
404
403
  failure = retry_error
405
404
  detail = f": {failure}" if isinstance(failure, Exception) else ""
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from if_cli.cache import store_tokens
6
+
7
+
8
+ def main() -> None:
9
+ name, host, token = sys.argv[1], sys.argv[2], sys.argv[3]
10
+ store_tokens(
11
+ {"name": name, "host": host}, {"access_token": token, "expires_in": 60}, "https://auth.example/token", "client"
12
+ )
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()