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.
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.github/workflows/claude.yml +8 -6
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/CHANGELOG.md +26 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/CLAUDE.md +3 -2
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/PKG-INFO +24 -9
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/README.md +23 -8
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/pyproject.toml +1 -1
- insightfactory_cli-1.3.0.dev39/src/if_cli/cache.py +318 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/logout.py +3 -4
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/profiles.py +3 -3
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/set_token.py +1 -1
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/oauth.py +13 -14
- insightfactory_cli-1.3.0.dev39/tests/cache_writer.py +16 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_attribution.py +1 -1
- insightfactory_cli-1.3.0.dev39/tests/test_cache.py +290 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_cli.py +2 -2
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_mcp_command.py +6 -2
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_oauth_flow.py +113 -21
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_oauth_force_refresh.py +3 -3
- insightfactory_cli-1.3.0.dev39/tests/test_profile_tokens.py +170 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_upstream.py +26 -2
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_set_token.py +1 -3
- insightfactory_cli-1.3.0.dev37/src/if_cli/cache.py +0 -206
- insightfactory_cli-1.3.0.dev37/tests/cache_writer.py +0 -14
- insightfactory_cli-1.3.0.dev37/tests/test_cache.py +0 -146
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.github/workflows/ci.yml +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.github/workflows/release.yml +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.gitignore +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.python-version +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/AGENTS.md +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/LICENSE +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/__init__.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/__main__.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/assets/__init__.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/attribution.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/callback_page.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/cli.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/colour.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/__init__.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/api.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/config.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/login.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/mcp.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/token.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/config.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/constants.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/http.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/main.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/__init__.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/catalog.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/content.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/log.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/policy.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/server.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/router/upstream.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/runtime.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/__init__.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/conftest.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/helpers.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/servers.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_api.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_api_command.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_config.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_config_command.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_login.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_oauth.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_profiles.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_programmatic_api.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_release_tools.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_catalog.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_content.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_log.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_policy.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_resources.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_router_server.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_runtime.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tests/test_token.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tools/check_release.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/tools/release_notes.py +0 -0
- {insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/uv.lock +0 -0
{insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/.github/workflows/claude.yml
RENAMED
|
@@ -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
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
26
|
-
`
|
|
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.
|
|
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`
|
|
32
|
-
|
|
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
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
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`
|
|
8
|
-
|
|
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
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
|
@@ -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)
|
{insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/logout.py
RENAMED
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
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")
|
{insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/profiles.py
RENAMED
|
@@ -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
|
|
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 =
|
|
102
|
+
cache: TokenCache = empty_cache()
|
|
103
103
|
cache_error: str | None = None
|
|
104
104
|
try:
|
|
105
105
|
cache = load_cache()
|
{insightfactory_cli-1.3.0.dev37 → insightfactory_cli-1.3.0.dev39}/src/if_cli/commands/set_token.py
RENAMED
|
@@ -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
|
|
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
|
|
338
|
+
return store_tokens(profile, tokens, metadata["token_endpoint"], client_id, replace=True)
|
|
338
339
|
|
|
339
340
|
|
|
340
|
-
def _refresh(
|
|
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(
|
|
350
|
+
return store_tokens(profile, tokens, entry["token_endpoint"], client_id)
|
|
350
351
|
|
|
351
352
|
|
|
352
|
-
def _wait_for_fresh_token(
|
|
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()
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
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
|
|
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()
|