taktcli 0.6.2__tar.gz → 0.7.0__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.
- {taktcli-0.6.2 → taktcli-0.7.0}/PKG-INFO +40 -23
- {taktcli-0.6.2 → taktcli-0.7.0}/README.md +39 -22
- {taktcli-0.6.2 → taktcli-0.7.0}/pyproject.toml +1 -1
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/__main__.py +8 -1
- taktcli-0.7.0/taktcli/auth.py +188 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/_exec.py +2 -2
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/_run.py +3 -3
- taktcli-0.7.0/taktcli/commands/auth.py +429 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/file.py +4 -4
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/gql.py +2 -2
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/schema.py +2 -2
- taktcli-0.7.0/taktcli/credentials.py +246 -0
- taktcli-0.7.0/taktcli/oauth.py +228 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/transport.py +36 -1
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/watch.py +60 -19
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/PKG-INFO +40 -23
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/SOURCES.txt +4 -0
- taktcli-0.7.0/tests/test_auth.py +261 -0
- taktcli-0.7.0/tests/test_auth_commands.py +459 -0
- taktcli-0.7.0/tests/test_credentials.py +183 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_gql.py +0 -1
- taktcli-0.7.0/tests/test_refresh_concurrency.py +64 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_watch.py +96 -1
- taktcli-0.6.2/taktcli/auth.py +0 -104
- taktcli-0.6.2/taktcli/commands/auth.py +0 -213
- taktcli-0.6.2/tests/test_auth.py +0 -75
- taktcli-0.6.2/tests/test_auth_commands.py +0 -268
- {taktcli-0.6.2 → taktcli-0.7.0}/setup.cfg +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/__init__.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/__init__.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/hotpath.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/label.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/notif.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/project.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/task.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/watch.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/wiki.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/wikis.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/workspace.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/errors.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/introspection.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/io.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/dependency_links.txt +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/entry_points.txt +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/requires.txt +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/top_level.txt +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_cli.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_file.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_hotpath.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_introspection.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_io.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_label.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_notif.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_project.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_schema.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_task.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_transport.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_version.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_wiki.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_wikis.py +0 -0
- {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taktcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core
|
|
5
5
|
Author: Takt
|
|
6
6
|
License: MIT
|
|
@@ -43,8 +43,8 @@ the supported replacement for the retired `tools/wait_for_task.py`.
|
|
|
43
43
|
## Quickstart
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
taktcli login #
|
|
47
|
-
taktcli whoami #
|
|
46
|
+
taktcli login # browser approval, stores a self-refreshing profile
|
|
47
|
+
taktcli whoami # subject, owner, workspace, expiry, credential source
|
|
48
48
|
taktcli board --project takt # show the board
|
|
49
49
|
taktcli claim takt-42 # READY → RUNNING, prints leaseId
|
|
50
50
|
taktcli run takt-42 # heartbeat the lease
|
|
@@ -58,33 +58,50 @@ lists a group's verbs.
|
|
|
58
58
|
|
|
59
59
|
Token resolution, simplest-wins:
|
|
60
60
|
|
|
61
|
-
1. **`TAKT_TOKEN` env** → used verbatim. This is what harness
|
|
62
|
-
stuffed with; env always wins over any stored credential.
|
|
63
|
-
2. Else
|
|
64
|
-
|
|
61
|
+
1. **`TAKT_TOKEN` env** → used verbatim and never refreshed. This is what harness
|
|
62
|
+
containers are stuffed with; env always wins over any stored credential.
|
|
63
|
+
2. Else the selected **profile** in `~/.takt/credentials.json`, written by
|
|
64
|
+
`taktcli login`. The profile is `--profile NAME` → `TAKT_PROFILE` → the
|
|
65
|
+
store's current profile → `default`.
|
|
65
66
|
3. Else error: `No Takt credentials found. Run: taktcli login` (exit `3`).
|
|
66
67
|
|
|
67
|
-
URL resolution mirrors it: `TAKT_URL` env →
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
URL resolution mirrors it: `TAKT_URL` env → the profile's URL → default
|
|
69
|
+
`https://api.takt.sh/graphql`. The `watch` WS endpoint is derived by swapping the
|
|
70
|
+
scheme (`https → wss`).
|
|
70
71
|
|
|
71
|
-
`taktcli login` runs the OAuth 2.0 **device
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
`taktcli login` runs the OAuth 2.0 **device grant** (RFC 8628). It prints
|
|
73
|
+
`Open <url> and enter <code>` to stderr, opens that page in your browser with the
|
|
74
|
+
code filled in (unless `--no-browser`, or there is no display), and waits for you
|
|
75
|
+
to approve. `--as <agent>` preselects an agent you own on the approval page, so
|
|
76
|
+
the CLI acts as that agent while you stay its owner.
|
|
75
77
|
|
|
76
78
|
```bash
|
|
77
|
-
taktcli login # default
|
|
78
|
-
taktcli login --
|
|
79
|
-
taktcli
|
|
80
|
-
taktcli
|
|
79
|
+
taktcli login # you, profile "default"
|
|
80
|
+
taktcli login --as claude-code --profile claude-code
|
|
81
|
+
taktcli login --no-browser # headless: just print the URL + code
|
|
82
|
+
taktcli login --url http://localhost:8454/graphql # another server
|
|
83
|
+
taktcli whoami # subject, owner, client, workspace, expiry, source
|
|
84
|
+
taktcli profile list # stored profiles (no secrets)
|
|
85
|
+
taktcli profile use claude-code
|
|
86
|
+
taktcli logout # revoke the grant, then delete the profile
|
|
81
87
|
```
|
|
82
88
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
The stored grant refreshes itself: an access token lasts an hour and is renewed
|
|
90
|
+
60 seconds before it expires (or once after the server rejects it), and the grant
|
|
91
|
+
lives as long as it is used at least once every 90 days. Refreshes take a lock on
|
|
92
|
+
`~/.takt/credentials.lock`, so parallel `taktcli` and `watch` processes share one
|
|
93
|
+
rotation. `logout` revokes the grant on the server before deleting it locally;
|
|
94
|
+
`logout --force` deletes even when the server can't be reached.
|
|
95
|
+
|
|
96
|
+
The file is mode `0600` in a `0700` directory and is replaced atomically;
|
|
97
|
+
taktcli refuses to read it through a symlink or with group/world permissions. A
|
|
98
|
+
`{ "token", "url" }` file from taktcli ≤ 0.6 keeps working until your next
|
|
99
|
+
`login` rewrites it.
|
|
100
|
+
|
|
101
|
+
`taktcli` adds **no** privilege surface: the token never exceeds what the
|
|
102
|
+
approving human can grant, there is no claims/permission verb anywhere, and admin
|
|
103
|
+
ops are reachable via `gql` only if the token already holds the `admin` claim.
|
|
104
|
+
Bots and harness images keep using `TAKT_TOKEN`.
|
|
88
105
|
|
|
89
106
|
## Passthrough — reaches 100% of GraphQL
|
|
90
107
|
|
|
@@ -27,8 +27,8 @@ the supported replacement for the retired `tools/wait_for_task.py`.
|
|
|
27
27
|
## Quickstart
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
taktcli login #
|
|
31
|
-
taktcli whoami #
|
|
30
|
+
taktcli login # browser approval, stores a self-refreshing profile
|
|
31
|
+
taktcli whoami # subject, owner, workspace, expiry, credential source
|
|
32
32
|
taktcli board --project takt # show the board
|
|
33
33
|
taktcli claim takt-42 # READY → RUNNING, prints leaseId
|
|
34
34
|
taktcli run takt-42 # heartbeat the lease
|
|
@@ -42,33 +42,50 @@ lists a group's verbs.
|
|
|
42
42
|
|
|
43
43
|
Token resolution, simplest-wins:
|
|
44
44
|
|
|
45
|
-
1. **`TAKT_TOKEN` env** → used verbatim. This is what harness
|
|
46
|
-
stuffed with; env always wins over any stored credential.
|
|
47
|
-
2. Else
|
|
48
|
-
|
|
45
|
+
1. **`TAKT_TOKEN` env** → used verbatim and never refreshed. This is what harness
|
|
46
|
+
containers are stuffed with; env always wins over any stored credential.
|
|
47
|
+
2. Else the selected **profile** in `~/.takt/credentials.json`, written by
|
|
48
|
+
`taktcli login`. The profile is `--profile NAME` → `TAKT_PROFILE` → the
|
|
49
|
+
store's current profile → `default`.
|
|
49
50
|
3. Else error: `No Takt credentials found. Run: taktcli login` (exit `3`).
|
|
50
51
|
|
|
51
|
-
URL resolution mirrors it: `TAKT_URL` env →
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
URL resolution mirrors it: `TAKT_URL` env → the profile's URL → default
|
|
53
|
+
`https://api.takt.sh/graphql`. The `watch` WS endpoint is derived by swapping the
|
|
54
|
+
scheme (`https → wss`).
|
|
54
55
|
|
|
55
|
-
`taktcli login` runs the OAuth 2.0 **device
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
`taktcli login` runs the OAuth 2.0 **device grant** (RFC 8628). It prints
|
|
57
|
+
`Open <url> and enter <code>` to stderr, opens that page in your browser with the
|
|
58
|
+
code filled in (unless `--no-browser`, or there is no display), and waits for you
|
|
59
|
+
to approve. `--as <agent>` preselects an agent you own on the approval page, so
|
|
60
|
+
the CLI acts as that agent while you stay its owner.
|
|
59
61
|
|
|
60
62
|
```bash
|
|
61
|
-
taktcli login # default
|
|
62
|
-
taktcli login --
|
|
63
|
-
taktcli
|
|
64
|
-
taktcli
|
|
63
|
+
taktcli login # you, profile "default"
|
|
64
|
+
taktcli login --as claude-code --profile claude-code
|
|
65
|
+
taktcli login --no-browser # headless: just print the URL + code
|
|
66
|
+
taktcli login --url http://localhost:8454/graphql # another server
|
|
67
|
+
taktcli whoami # subject, owner, client, workspace, expiry, source
|
|
68
|
+
taktcli profile list # stored profiles (no secrets)
|
|
69
|
+
taktcli profile use claude-code
|
|
70
|
+
taktcli logout # revoke the grant, then delete the profile
|
|
65
71
|
```
|
|
66
72
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
The stored grant refreshes itself: an access token lasts an hour and is renewed
|
|
74
|
+
60 seconds before it expires (or once after the server rejects it), and the grant
|
|
75
|
+
lives as long as it is used at least once every 90 days. Refreshes take a lock on
|
|
76
|
+
`~/.takt/credentials.lock`, so parallel `taktcli` and `watch` processes share one
|
|
77
|
+
rotation. `logout` revokes the grant on the server before deleting it locally;
|
|
78
|
+
`logout --force` deletes even when the server can't be reached.
|
|
79
|
+
|
|
80
|
+
The file is mode `0600` in a `0700` directory and is replaced atomically;
|
|
81
|
+
taktcli refuses to read it through a symlink or with group/world permissions. A
|
|
82
|
+
`{ "token", "url" }` file from taktcli ≤ 0.6 keeps working until your next
|
|
83
|
+
`login` rewrites it.
|
|
84
|
+
|
|
85
|
+
`taktcli` adds **no** privilege surface: the token never exceeds what the
|
|
86
|
+
approving human can grant, there is no claims/permission verb anywhere, and admin
|
|
87
|
+
ops are reachable via `gql` only if the token already holds the `admin` claim.
|
|
88
|
+
Bots and harness images keep using `TAKT_TOKEN`.
|
|
72
89
|
|
|
73
90
|
## Passthrough — reaches 100% of GraphQL
|
|
74
91
|
|
|
@@ -8,7 +8,7 @@ default 2 (which the contract reserves for transport failures).
|
|
|
8
8
|
import argparse
|
|
9
9
|
import sys
|
|
10
10
|
|
|
11
|
-
from . import __version__, commands
|
|
11
|
+
from . import __version__, auth, commands
|
|
12
12
|
from .errors import TaktCliError
|
|
13
13
|
|
|
14
14
|
DOCS_HINT = (
|
|
@@ -34,6 +34,12 @@ def build_parser() -> TaktArgumentParser:
|
|
|
34
34
|
epilog=DOCS_HINT,
|
|
35
35
|
)
|
|
36
36
|
parser.add_argument("--version", action="version", version=f"taktcli {__version__}")
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--profile",
|
|
39
|
+
metavar="NAME",
|
|
40
|
+
default=None,
|
|
41
|
+
help="credential profile from ~/.takt/credentials.json (default: TAKT_PROFILE, else the current one)",
|
|
42
|
+
)
|
|
37
43
|
subparsers = parser.add_subparsers(dest="command", metavar="<command>", required=True)
|
|
38
44
|
commands.register(subparsers)
|
|
39
45
|
help_parser = subparsers.add_parser("help", help="show this help message and exit")
|
|
@@ -49,6 +55,7 @@ def main(argv=None) -> int:
|
|
|
49
55
|
if handler is None:
|
|
50
56
|
parser.print_help(sys.stderr)
|
|
51
57
|
return 3
|
|
58
|
+
auth.set_profile_override(getattr(args, "profile", None))
|
|
52
59
|
try:
|
|
53
60
|
result = handler(args)
|
|
54
61
|
except TaktCliError as exc:
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Credential + URL resolution and silent refresh (PRD 002 §6, PRD-004 §9, [[takt-468]]).
|
|
2
|
+
|
|
3
|
+
Token: ``TAKT_TOKEN`` env (verbatim, never refreshed) -> the selected profile -> exit 3
|
|
4
|
+
Profile: ``--profile`` -> ``TAKT_PROFILE`` -> the store's ``current_profile`` -> ``default``
|
|
5
|
+
URL: ``TAKT_URL`` env -> the profile's ``url`` -> default
|
|
6
|
+
|
|
7
|
+
An explicit token never changes which URL is chosen. A profile that carries a refresh token is
|
|
8
|
+
refreshed 60s before its expiry and once more after a rejected request, under a cross-process
|
|
9
|
+
lock with a re-read: when another process already rotated, its tokens are used as they are.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from . import oauth
|
|
16
|
+
from .credentials import DEFAULT_PROFILE, CredentialStore, expires_at_from, expiring
|
|
17
|
+
from .errors import ConfigError
|
|
18
|
+
|
|
19
|
+
DEFAULT_URL = "https://api.takt.sh/graphql"
|
|
20
|
+
LOGIN_HINT = "No Takt credentials found. Run: taktcli login"
|
|
21
|
+
REFRESH_LEEWAY_SECONDS = 60
|
|
22
|
+
|
|
23
|
+
_profile_override: str | None = None
|
|
24
|
+
_resolved_profiles: dict[str, str] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_profile_override(name: str | None) -> None:
|
|
28
|
+
"""Pin the profile for this process (the ``--profile`` flag); ``None`` clears it."""
|
|
29
|
+
global _profile_override
|
|
30
|
+
_profile_override = name or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def profile_name(payload: dict | None = None) -> str:
|
|
34
|
+
"""The selected profile: ``--profile`` -> ``TAKT_PROFILE`` -> current -> ``default``."""
|
|
35
|
+
explicit = _profile_override or os.environ.get("TAKT_PROFILE")
|
|
36
|
+
if explicit:
|
|
37
|
+
return explicit
|
|
38
|
+
if payload is not None and payload.get("current_profile"):
|
|
39
|
+
return payload["current_profile"]
|
|
40
|
+
return DEFAULT_PROFILE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _profile_explicit() -> bool:
|
|
44
|
+
return bool(_profile_override or os.environ.get("TAKT_PROFILE"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Credential:
|
|
49
|
+
"""The credential a command will use, and where it came from."""
|
|
50
|
+
|
|
51
|
+
url: str
|
|
52
|
+
token: str
|
|
53
|
+
source: str
|
|
54
|
+
profile_name: str | None = None
|
|
55
|
+
profile: dict | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def refreshable(self) -> bool:
|
|
59
|
+
return bool(self.profile and self.profile.get("refresh_token") and self.profile.get("origin"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_origin(url: str, name: str, profile: dict) -> None:
|
|
63
|
+
"""Refuse to send an OAuth profile's tokens to a ``TAKT_URL`` on another origin."""
|
|
64
|
+
origin = profile.get("origin")
|
|
65
|
+
if origin and oauth.origin_from_url(url) != origin.rstrip("/"):
|
|
66
|
+
raise ConfigError(
|
|
67
|
+
f"Profile {name!r} was issued by {origin}, but TAKT_URL points at {url}. "
|
|
68
|
+
f"Unset TAKT_URL, pick another profile with --profile, or run: taktcli login --profile {name} --url {url}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def select(store: CredentialStore | None = None) -> Credential:
|
|
73
|
+
"""Resolve the active credential without refreshing it."""
|
|
74
|
+
env_url = os.environ.get("TAKT_URL")
|
|
75
|
+
env_token = os.environ.get("TAKT_TOKEN")
|
|
76
|
+
if env_token:
|
|
77
|
+
if env_url:
|
|
78
|
+
return Credential(env_url, env_token, "TAKT_TOKEN env")
|
|
79
|
+
store = store or CredentialStore()
|
|
80
|
+
payload = store.read(strict=False)
|
|
81
|
+
profile = payload["profiles"].get(profile_name(payload)) or {}
|
|
82
|
+
return Credential(profile.get("url") or DEFAULT_URL, env_token, "TAKT_TOKEN env")
|
|
83
|
+
store = store or CredentialStore()
|
|
84
|
+
payload = store.read()
|
|
85
|
+
name = profile_name(payload)
|
|
86
|
+
profile = payload["profiles"].get(name)
|
|
87
|
+
if not profile or not profile.get("access_token"):
|
|
88
|
+
if payload["profiles"] and (_profile_explicit() or name not in payload["profiles"]):
|
|
89
|
+
raise ConfigError(f"No Takt credentials for profile {name!r}. Run: taktcli login --profile {name}")
|
|
90
|
+
raise ConfigError(LOGIN_HINT)
|
|
91
|
+
url = env_url or profile.get("url") or DEFAULT_URL
|
|
92
|
+
if env_url:
|
|
93
|
+
_check_origin(env_url, name, profile)
|
|
94
|
+
return Credential(url, profile["access_token"], f"profile {name!r} ({store.path})", name, profile)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def refresh_profile(name: str, expected_token: str, *, store: CredentialStore | None = None, opener=None) -> dict:
|
|
98
|
+
"""Rotate profile ``name``'s tokens under the cross-process lock; return the fresh profile.
|
|
99
|
+
|
|
100
|
+
Inside the lock the store is re-read: if another process already replaced
|
|
101
|
+
``expected_token`` with a still-valid one, that is returned untouched. An ``invalid_grant``
|
|
102
|
+
(a replayed or revoked refresh token, PRD-004 D10) re-reads the store once more before
|
|
103
|
+
giving up with "run taktcli login" (exit 3).
|
|
104
|
+
"""
|
|
105
|
+
store = store or CredentialStore()
|
|
106
|
+
with store.lock():
|
|
107
|
+
profile = store.read()["profiles"].get(name)
|
|
108
|
+
if not profile or not profile.get("access_token"):
|
|
109
|
+
raise ConfigError(f"Profile {name!r} was logged out. Run: taktcli login --profile {name}")
|
|
110
|
+
if profile["access_token"] != expected_token and not expiring(profile, REFRESH_LEEWAY_SECONDS):
|
|
111
|
+
return profile
|
|
112
|
+
if not profile.get("refresh_token") or not profile.get("origin"):
|
|
113
|
+
raise ConfigError(f"Profile {name!r} cannot be refreshed. Run: taktcli login --profile {name}")
|
|
114
|
+
try:
|
|
115
|
+
body = oauth.refresh(
|
|
116
|
+
profile["origin"], profile["refresh_token"], profile.get("client_id") or oauth.CLIENT_ID, opener=opener
|
|
117
|
+
)
|
|
118
|
+
except oauth.OAuthError as exc:
|
|
119
|
+
if exc.error == "invalid_grant":
|
|
120
|
+
current = store.read()["profiles"].get(name) or {}
|
|
121
|
+
if current.get("refresh_token") not in (None, profile["refresh_token"]) and not expiring(current, 0):
|
|
122
|
+
return current
|
|
123
|
+
raise ConfigError(
|
|
124
|
+
f"The session for profile {name!r} expired or was revoked. Run: taktcli login --profile {name}"
|
|
125
|
+
) from exc
|
|
126
|
+
raise ConfigError(f"Token refresh failed: {exc}") from exc
|
|
127
|
+
fresh = apply_token_response(profile, body)
|
|
128
|
+
store.save_profile(name, fresh, make_current=False)
|
|
129
|
+
return fresh
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def apply_token_response(profile: dict, body: dict) -> dict:
|
|
133
|
+
"""``profile`` updated from an RFC 6749 §5.1 token response (rotated refresh token included)."""
|
|
134
|
+
try:
|
|
135
|
+
access_token = body["access_token"]
|
|
136
|
+
except KeyError as exc:
|
|
137
|
+
raise ConfigError("The token endpoint returned no access_token") from exc
|
|
138
|
+
fresh = dict(profile)
|
|
139
|
+
fresh["access_token"] = access_token
|
|
140
|
+
fresh["refresh_token"] = body.get("refresh_token") or profile.get("refresh_token")
|
|
141
|
+
fresh["expires_at"] = expires_at_from(body.get("expires_in"))
|
|
142
|
+
if body.get("scope") is not None:
|
|
143
|
+
fresh["scope"] = body["scope"]
|
|
144
|
+
return fresh
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve_credential(store: CredentialStore | None = None, opener=None) -> Credential:
|
|
148
|
+
"""The active credential, refreshed first when it expires within 60s."""
|
|
149
|
+
store = store or CredentialStore()
|
|
150
|
+
cred = select(store)
|
|
151
|
+
if cred.refreshable and expiring(cred.profile, REFRESH_LEEWAY_SECONDS):
|
|
152
|
+
fresh = refresh_profile(cred.profile_name, cred.token, store=store, opener=opener)
|
|
153
|
+
cred = Credential(cred.url, fresh["access_token"], cred.source, cred.profile_name, fresh)
|
|
154
|
+
if cred.profile_name is not None:
|
|
155
|
+
_resolved_profiles[cred.token] = cred.profile_name
|
|
156
|
+
return cred
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_auth() -> tuple[str, str]:
|
|
160
|
+
"""``(url, token)`` for a GraphQL call, refreshing a stored OAuth profile when due."""
|
|
161
|
+
cred = resolve_credential()
|
|
162
|
+
return cred.url, cred.token
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def reauthorize(rejected_token: str, *, store: CredentialStore | None = None, opener=None) -> str | None:
|
|
166
|
+
"""A replacement for ``rejected_token`` after the server refused it, or ``None``.
|
|
167
|
+
|
|
168
|
+
Only a token this process resolved from a stored profile is replaced, and only from that
|
|
169
|
+
same profile, so a retry can never switch identity (``TAKT_TOKEN`` and anything not
|
|
170
|
+
resolved here get ``None``). When the profile already holds a newer token (another
|
|
171
|
+
process rotated) that one is returned; otherwise the profile is refreshed once.
|
|
172
|
+
"""
|
|
173
|
+
name = _resolved_profiles.get(rejected_token)
|
|
174
|
+
if name is None or os.environ.get("TAKT_TOKEN"):
|
|
175
|
+
return None
|
|
176
|
+
store = store or CredentialStore()
|
|
177
|
+
profile = store.read()["profiles"].get(name) or {}
|
|
178
|
+
current = profile.get("access_token")
|
|
179
|
+
if not current:
|
|
180
|
+
return None
|
|
181
|
+
if current != rejected_token:
|
|
182
|
+
_resolved_profiles[current] = name
|
|
183
|
+
return current
|
|
184
|
+
if not (profile.get("refresh_token") and profile.get("origin")):
|
|
185
|
+
return None
|
|
186
|
+
fresh = refresh_profile(name, rejected_token, store=store, opener=opener)["access_token"]
|
|
187
|
+
_resolved_profiles[fresh] = name
|
|
188
|
+
return fresh
|
|
@@ -10,7 +10,7 @@ import argparse
|
|
|
10
10
|
import os
|
|
11
11
|
import sys
|
|
12
12
|
|
|
13
|
-
from ..auth import resolve_auth
|
|
13
|
+
from ..auth import reauthorize, resolve_auth
|
|
14
14
|
from ..errors import ConfigError
|
|
15
15
|
from ..io import format_output, read_path
|
|
16
16
|
from ..transport import execute
|
|
@@ -34,7 +34,7 @@ def add_output_args(parser: argparse.ArgumentParser) -> None:
|
|
|
34
34
|
def fetch(document: str, variables: dict) -> dict:
|
|
35
35
|
"""Resolve auth, run the operation, and return the full GraphQL envelope."""
|
|
36
36
|
url, token = resolve_auth()
|
|
37
|
-
return execute(document, variables, url=url, token=token)
|
|
37
|
+
return execute(document, variables, url=url, token=token, reauthorize=reauthorize)
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def run_operation(args, document: str, variables: dict) -> int:
|
|
@@ -8,7 +8,7 @@ resolving an inline-or-file (``-`` = stdin) text argument.
|
|
|
8
8
|
|
|
9
9
|
import sys
|
|
10
10
|
|
|
11
|
-
from ..auth import resolve_auth
|
|
11
|
+
from ..auth import reauthorize, resolve_auth
|
|
12
12
|
from ..errors import ConfigError
|
|
13
13
|
from ..io import format_output, read_path
|
|
14
14
|
from ..transport import execute
|
|
@@ -30,7 +30,7 @@ def run_op(args, document: str, variables: dict, *, hint=None, unwrap=None) -> i
|
|
|
30
30
|
which still sees the full ``.data``.
|
|
31
31
|
"""
|
|
32
32
|
url, token = resolve_auth()
|
|
33
|
-
envelope = execute(document, variables, url=url, token=token)
|
|
33
|
+
envelope = execute(document, variables, url=url, token=token, reauthorize=reauthorize)
|
|
34
34
|
print(format_output(
|
|
35
35
|
envelope,
|
|
36
36
|
raw=getattr(args, "raw", False),
|
|
@@ -52,7 +52,7 @@ def fetch_data(document: str, variables: dict) -> dict:
|
|
|
52
52
|
resolving a label name to its id) without printing the intermediate result.
|
|
53
53
|
"""
|
|
54
54
|
url, token = resolve_auth()
|
|
55
|
-
envelope = execute(document, variables, url=url, token=token)
|
|
55
|
+
envelope = execute(document, variables, url=url, token=token, reauthorize=reauthorize)
|
|
56
56
|
return (envelope or {}).get("data") or {}
|
|
57
57
|
|
|
58
58
|
|