pyclilogin 0.1.1__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.
- pyclilogin-0.1.1/.gitignore +13 -0
- pyclilogin-0.1.1/LICENSE +21 -0
- pyclilogin-0.1.1/PKG-INFO +211 -0
- pyclilogin-0.1.1/README.md +184 -0
- pyclilogin-0.1.1/pyproject.toml +218 -0
- pyclilogin-0.1.1/src/pyclilogin/__init__.py +93 -0
- pyclilogin-0.1.1/src/pyclilogin/deployment.py +453 -0
- pyclilogin-0.1.1/src/pyclilogin/errors.py +85 -0
- pyclilogin-0.1.1/src/pyclilogin/product.py +190 -0
- pyclilogin-0.1.1/src/pyclilogin/provider.py +266 -0
- pyclilogin-0.1.1/src/pyclilogin/py.typed +0 -0
- pyclilogin-0.1.1/src/pyclilogin/session.py +207 -0
- pyclilogin-0.1.1/src/pyclilogin/store.py +236 -0
- pyclilogin-0.1.1/src/pyclilogin/testing.py +50 -0
- pyclilogin-0.1.1/src/pyclilogin/typercmd.py +367 -0
pyclilogin-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Birch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyclilogin
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Device-grant login for Python CLIs: one config file, a keychain token store with a visible fallback, and a refresh serialized across processes
|
|
5
|
+
Project-URL: Repository, https://github.com/datapointchris/pyclilogin
|
|
6
|
+
Project-URL: Issues, https://github.com/datapointchris/pyclilogin/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/datapointchris/pyclilogin/releases
|
|
8
|
+
Author-email: Chris Birch <datapointchris@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,device-authorization-grant,keyring,login,oauth2,oidc
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx2>=2.9.1
|
|
23
|
+
Requires-Dist: keyring>=25
|
|
24
|
+
Provides-Extra: typer
|
|
25
|
+
Requires-Dist: typer>=0.15.0; extra == 'typer'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pyclilogin
|
|
29
|
+
|
|
30
|
+
OIDC device-grant login for Python command-line tools. Tokens live in the OS
|
|
31
|
+
keychain, and the refresh is serialized across every process on the machine.
|
|
32
|
+
The same tool run by a service, with no person to approve a login, uses the
|
|
33
|
+
client-credentials grant instead.
|
|
34
|
+
|
|
35
|
+
It is the Python sibling of
|
|
36
|
+
[goclilogin](https://github.com/datapointchris/goclilogin), and keeps its
|
|
37
|
+
contract point for point: the same config file, the same keychain fallback, the
|
|
38
|
+
same refresh lock. A Go CLI and a Python CLI built on the two log in, store and
|
|
39
|
+
refresh the same way. Its server-side counterpart is
|
|
40
|
+
[pyapiauth](https://github.com/datapointchris/pyapiauth), which verifies the
|
|
41
|
+
tokens this package obtains.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv add pyclilogin # the core
|
|
47
|
+
uv add "pyclilogin[typer]" # plus the ready-made `auth` commands
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Starting from scratch
|
|
51
|
+
|
|
52
|
+
A CLI called `acme` describes itself once, as a `Product`, and mounts the
|
|
53
|
+
`auth` commands on its typer app:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from contextvars import ContextVar
|
|
57
|
+
|
|
58
|
+
import typer
|
|
59
|
+
from pyclilogin import Product
|
|
60
|
+
from pyclilogin.typercmd import add_auth_commands
|
|
61
|
+
|
|
62
|
+
PRODUCT = Product(name='acme', keyring_service='acme-cli')
|
|
63
|
+
API_FLAG: ContextVar[str] = ContextVar('api_flag', default='')
|
|
64
|
+
|
|
65
|
+
app = typer.Typer()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.callback()
|
|
69
|
+
def root(api: str = typer.Option('', '--api')):
|
|
70
|
+
API_FLAG.set(api)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
add_auth_commands(app, PRODUCT, api_flag=API_FLAG.get)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That gives `acme auth login [URL]`, `acme auth logout`, `acme auth token` and
|
|
77
|
+
`acme auth status`. Every other command gets its token the same way:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import httpx2
|
|
81
|
+
|
|
82
|
+
resolved = PRODUCT.load(API_FLAG.get()) # the config file, with --api outranking it
|
|
83
|
+
source = PRODUCT.token_source(resolved) # NotLoggedIn where nothing is stored
|
|
84
|
+
response = httpx2.get(
|
|
85
|
+
f'{resolved.api.value}/items',
|
|
86
|
+
headers={'Authorization': f'Bearer {source.token().access_token}'}, # refreshed first if it lapsed
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The first run needs three things from outside the CLI:
|
|
91
|
+
|
|
92
|
+
- The provider advertises a `device_authorization_endpoint` in its OIDC
|
|
93
|
+
discovery document.
|
|
94
|
+
- The client id is registered at the provider as a public client with no
|
|
95
|
+
secret.
|
|
96
|
+
- The API serves RFC 9728 protected-resource metadata naming its login server.
|
|
97
|
+
A `config.toml` that already sets `issuer` skips this.
|
|
98
|
+
|
|
99
|
+
Every error is a `LoginError` whose message names its fix, so a CLI's entry
|
|
100
|
+
point catches that one class and prints the message.
|
|
101
|
+
|
|
102
|
+
## Which call answers which question
|
|
103
|
+
|
|
104
|
+
| Ask | Call | Reads |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| Where is this CLI pointed | `Product.load`, `write_deployment` | the config file, and an `--api`'s RFC 9728 metadata where it differs from the file's |
|
|
107
|
+
| What does a first login fill in | `Product.for_login` | the config file, then the API's RFC 9728 metadata for a missing `issuer` |
|
|
108
|
+
| How does a person log in | `Provider.login` | the issuer's discovery document, then the device grant |
|
|
109
|
+
| Where did the token go | `TokenStore.save`, `TokenStore.load` | the OS keychain, then the mode-600 file in the state directory |
|
|
110
|
+
| How do requests get a token | `Product.token_source` | the store, then on a refresh the refresh lock and the discovery document |
|
|
111
|
+
| How does a service with no person authenticate | `ServiceTokenSource` | the token endpoint, with the client's secret |
|
|
112
|
+
| How does a CLI use tokens a broker hands it | `CommandTokenSource` | the stdout of `token_command` from the config file |
|
|
113
|
+
| How do my tests avoid the real keychain | `pyclilogin.testing.MemoryKeyring` | a dict |
|
|
114
|
+
|
|
115
|
+
## One config file says where the CLI is pointed, and only login writes it
|
|
116
|
+
|
|
117
|
+
`$XDG_CONFIG_HOME/<name>/config.toml` holds three keys:
|
|
118
|
+
|
|
119
|
+
```toml
|
|
120
|
+
api = "https://acme.example.com"
|
|
121
|
+
issuer = "https://auth.example.com"
|
|
122
|
+
client_id = "acme-cli-laptop"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Every command reads it through `Product.load`. An explicit `--api` outranks the
|
|
126
|
+
file's. Nothing else is read: no environment variable, and no address compiled
|
|
127
|
+
into the package, because each value names one installation.
|
|
128
|
+
|
|
129
|
+
`acme auth login https://acme.example.com` fills in only what the file lacks.
|
|
130
|
+
`api` comes from the URL, `issuer` from the API's
|
|
131
|
+
`/.well-known/oauth-protected-resource`, and `client_id` from `--client-id` or
|
|
132
|
+
`acme-cli-<short hostname>`. Each is printed with where it came from before the
|
|
133
|
+
grant starts, and written once the grant succeeds. A value the file already
|
|
134
|
+
holds is never rewritten, and a typed value differing from it is refused.
|
|
135
|
+
|
|
136
|
+
An `--api` differing from the file's must name the file's issuer in its own
|
|
137
|
+
metadata. Otherwise the command is refused before any request carries a token.
|
|
138
|
+
|
|
139
|
+
Login never writes through a symlink. A link means a config manager owns the
|
|
140
|
+
file, and its source may be every machine's. A client id written there would
|
|
141
|
+
become every machine's.
|
|
142
|
+
|
|
143
|
+
A key outside these three belongs to the product, as in goclilogin. A product
|
|
144
|
+
whose file holds nothing else passes `product_keys=frozenset()`, and a renamed
|
|
145
|
+
key such as `api_base` is then refused rather than read as a file with no
|
|
146
|
+
`api`.
|
|
147
|
+
|
|
148
|
+
## The refresh is serialized across every process on the machine
|
|
149
|
+
|
|
150
|
+
A provider following RFC 9700 rotates the refresh token on every use, and
|
|
151
|
+
revokes the whole grant when a consumed one is presented again. Two processes
|
|
152
|
+
refreshing from the same stored token therefore cost an interactive login.
|
|
153
|
+
One rotates, and the other replays the token that rotation consumed.
|
|
154
|
+
|
|
155
|
+
`TokenSource.token` holds an exclusive `flock` in the state directory for the
|
|
156
|
+
refresh, and reads the store again once it has the lock. A process that waited
|
|
157
|
+
uses the winner's token instead of replaying its own. A lock that cannot be
|
|
158
|
+
taken within ten seconds gives an unlocked refresh. That risks one login, where
|
|
159
|
+
blocking on a wedged holder would hang every command.
|
|
160
|
+
|
|
161
|
+
A command holding a live token makes no call to the provider at all.
|
|
162
|
+
|
|
163
|
+
## The token goes to the keychain, and the downgrade is visible
|
|
164
|
+
|
|
165
|
+
`TokenStore.save` returns the backend that took the token. On a Linux host with
|
|
166
|
+
no Secret Service, that is a mode-600 `token.json` in the state directory, and
|
|
167
|
+
`auth login` says so. A macOS keychain locked to an SSH session is refused
|
|
168
|
+
instead, with `security unlock-keychain` as the fix. A plaintext copy written
|
|
169
|
+
there would outlive the lock. `TokenStore.delete` clears both stores, so a file
|
|
170
|
+
token written before a host had a keyring cannot outlive the logout.
|
|
171
|
+
|
|
172
|
+
## A service authenticates as itself, never as a person
|
|
173
|
+
|
|
174
|
+
With `ACME_CLIENT_SECRET` set, `Product.token_source` authenticates as the
|
|
175
|
+
config file's `client_id` with the client-credentials grant. The variable is
|
|
176
|
+
`<NAME>_CLIENT_SECRET` unless `Product.secret_variable` names another. The
|
|
177
|
+
secret stays in the environment, because `auth status` prints the file.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
source = PRODUCT.token_source(resolved, service_scopes=('acme.items.read',))
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A service's token carries only the scopes its command names, so a command
|
|
184
|
+
naming none is refused before any request. Nothing is stored and no lock is
|
|
185
|
+
taken: the grant issues no refresh token. Name the service's client so the API
|
|
186
|
+
can tell it from a person's, such as `acme-svc-scheduler`.
|
|
187
|
+
|
|
188
|
+
## A broker's token command replaces the login
|
|
189
|
+
|
|
190
|
+
A workload whose broker hands it short-lived tokens names the broker's command
|
|
191
|
+
in the file:
|
|
192
|
+
|
|
193
|
+
```toml
|
|
194
|
+
api = "https://acme.example.com"
|
|
195
|
+
token_command = ["broker", "token", "--audience", "acme"]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The command prints an RFC 6749 § 5.1 token response. Every request carries its
|
|
199
|
+
`access_token`, and the command runs again once `expires_in` lapses.
|
|
200
|
+
|
|
201
|
+
## Linux and macOS
|
|
202
|
+
|
|
203
|
+
The refresh lock is `fcntl.flock`, so the package runs on Linux and macOS.
|
|
204
|
+
Releases are cut only from a commit whose suite passes on both, on Python 3.11
|
|
205
|
+
and 3.13.
|
|
206
|
+
|
|
207
|
+
## What it deliberately does not do
|
|
208
|
+
|
|
209
|
+
It is not a general OIDC client. It has no ID token verification, no
|
|
210
|
+
back-channel logout, no DPoP and no mTLS-bound tokens. An API that verifies
|
|
211
|
+
tokens against the provider's JWKS, as pyapiauth does, needs none of them.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# pyclilogin
|
|
2
|
+
|
|
3
|
+
OIDC device-grant login for Python command-line tools. Tokens live in the OS
|
|
4
|
+
keychain, and the refresh is serialized across every process on the machine.
|
|
5
|
+
The same tool run by a service, with no person to approve a login, uses the
|
|
6
|
+
client-credentials grant instead.
|
|
7
|
+
|
|
8
|
+
It is the Python sibling of
|
|
9
|
+
[goclilogin](https://github.com/datapointchris/goclilogin), and keeps its
|
|
10
|
+
contract point for point: the same config file, the same keychain fallback, the
|
|
11
|
+
same refresh lock. A Go CLI and a Python CLI built on the two log in, store and
|
|
12
|
+
refresh the same way. Its server-side counterpart is
|
|
13
|
+
[pyapiauth](https://github.com/datapointchris/pyapiauth), which verifies the
|
|
14
|
+
tokens this package obtains.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv add pyclilogin # the core
|
|
20
|
+
uv add "pyclilogin[typer]" # plus the ready-made `auth` commands
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Starting from scratch
|
|
24
|
+
|
|
25
|
+
A CLI called `acme` describes itself once, as a `Product`, and mounts the
|
|
26
|
+
`auth` commands on its typer app:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from contextvars import ContextVar
|
|
30
|
+
|
|
31
|
+
import typer
|
|
32
|
+
from pyclilogin import Product
|
|
33
|
+
from pyclilogin.typercmd import add_auth_commands
|
|
34
|
+
|
|
35
|
+
PRODUCT = Product(name='acme', keyring_service='acme-cli')
|
|
36
|
+
API_FLAG: ContextVar[str] = ContextVar('api_flag', default='')
|
|
37
|
+
|
|
38
|
+
app = typer.Typer()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.callback()
|
|
42
|
+
def root(api: str = typer.Option('', '--api')):
|
|
43
|
+
API_FLAG.set(api)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
add_auth_commands(app, PRODUCT, api_flag=API_FLAG.get)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That gives `acme auth login [URL]`, `acme auth logout`, `acme auth token` and
|
|
50
|
+
`acme auth status`. Every other command gets its token the same way:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import httpx2
|
|
54
|
+
|
|
55
|
+
resolved = PRODUCT.load(API_FLAG.get()) # the config file, with --api outranking it
|
|
56
|
+
source = PRODUCT.token_source(resolved) # NotLoggedIn where nothing is stored
|
|
57
|
+
response = httpx2.get(
|
|
58
|
+
f'{resolved.api.value}/items',
|
|
59
|
+
headers={'Authorization': f'Bearer {source.token().access_token}'}, # refreshed first if it lapsed
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The first run needs three things from outside the CLI:
|
|
64
|
+
|
|
65
|
+
- The provider advertises a `device_authorization_endpoint` in its OIDC
|
|
66
|
+
discovery document.
|
|
67
|
+
- The client id is registered at the provider as a public client with no
|
|
68
|
+
secret.
|
|
69
|
+
- The API serves RFC 9728 protected-resource metadata naming its login server.
|
|
70
|
+
A `config.toml` that already sets `issuer` skips this.
|
|
71
|
+
|
|
72
|
+
Every error is a `LoginError` whose message names its fix, so a CLI's entry
|
|
73
|
+
point catches that one class and prints the message.
|
|
74
|
+
|
|
75
|
+
## Which call answers which question
|
|
76
|
+
|
|
77
|
+
| Ask | Call | Reads |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| Where is this CLI pointed | `Product.load`, `write_deployment` | the config file, and an `--api`'s RFC 9728 metadata where it differs from the file's |
|
|
80
|
+
| What does a first login fill in | `Product.for_login` | the config file, then the API's RFC 9728 metadata for a missing `issuer` |
|
|
81
|
+
| How does a person log in | `Provider.login` | the issuer's discovery document, then the device grant |
|
|
82
|
+
| Where did the token go | `TokenStore.save`, `TokenStore.load` | the OS keychain, then the mode-600 file in the state directory |
|
|
83
|
+
| How do requests get a token | `Product.token_source` | the store, then on a refresh the refresh lock and the discovery document |
|
|
84
|
+
| How does a service with no person authenticate | `ServiceTokenSource` | the token endpoint, with the client's secret |
|
|
85
|
+
| How does a CLI use tokens a broker hands it | `CommandTokenSource` | the stdout of `token_command` from the config file |
|
|
86
|
+
| How do my tests avoid the real keychain | `pyclilogin.testing.MemoryKeyring` | a dict |
|
|
87
|
+
|
|
88
|
+
## One config file says where the CLI is pointed, and only login writes it
|
|
89
|
+
|
|
90
|
+
`$XDG_CONFIG_HOME/<name>/config.toml` holds three keys:
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
api = "https://acme.example.com"
|
|
94
|
+
issuer = "https://auth.example.com"
|
|
95
|
+
client_id = "acme-cli-laptop"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Every command reads it through `Product.load`. An explicit `--api` outranks the
|
|
99
|
+
file's. Nothing else is read: no environment variable, and no address compiled
|
|
100
|
+
into the package, because each value names one installation.
|
|
101
|
+
|
|
102
|
+
`acme auth login https://acme.example.com` fills in only what the file lacks.
|
|
103
|
+
`api` comes from the URL, `issuer` from the API's
|
|
104
|
+
`/.well-known/oauth-protected-resource`, and `client_id` from `--client-id` or
|
|
105
|
+
`acme-cli-<short hostname>`. Each is printed with where it came from before the
|
|
106
|
+
grant starts, and written once the grant succeeds. A value the file already
|
|
107
|
+
holds is never rewritten, and a typed value differing from it is refused.
|
|
108
|
+
|
|
109
|
+
An `--api` differing from the file's must name the file's issuer in its own
|
|
110
|
+
metadata. Otherwise the command is refused before any request carries a token.
|
|
111
|
+
|
|
112
|
+
Login never writes through a symlink. A link means a config manager owns the
|
|
113
|
+
file, and its source may be every machine's. A client id written there would
|
|
114
|
+
become every machine's.
|
|
115
|
+
|
|
116
|
+
A key outside these three belongs to the product, as in goclilogin. A product
|
|
117
|
+
whose file holds nothing else passes `product_keys=frozenset()`, and a renamed
|
|
118
|
+
key such as `api_base` is then refused rather than read as a file with no
|
|
119
|
+
`api`.
|
|
120
|
+
|
|
121
|
+
## The refresh is serialized across every process on the machine
|
|
122
|
+
|
|
123
|
+
A provider following RFC 9700 rotates the refresh token on every use, and
|
|
124
|
+
revokes the whole grant when a consumed one is presented again. Two processes
|
|
125
|
+
refreshing from the same stored token therefore cost an interactive login.
|
|
126
|
+
One rotates, and the other replays the token that rotation consumed.
|
|
127
|
+
|
|
128
|
+
`TokenSource.token` holds an exclusive `flock` in the state directory for the
|
|
129
|
+
refresh, and reads the store again once it has the lock. A process that waited
|
|
130
|
+
uses the winner's token instead of replaying its own. A lock that cannot be
|
|
131
|
+
taken within ten seconds gives an unlocked refresh. That risks one login, where
|
|
132
|
+
blocking on a wedged holder would hang every command.
|
|
133
|
+
|
|
134
|
+
A command holding a live token makes no call to the provider at all.
|
|
135
|
+
|
|
136
|
+
## The token goes to the keychain, and the downgrade is visible
|
|
137
|
+
|
|
138
|
+
`TokenStore.save` returns the backend that took the token. On a Linux host with
|
|
139
|
+
no Secret Service, that is a mode-600 `token.json` in the state directory, and
|
|
140
|
+
`auth login` says so. A macOS keychain locked to an SSH session is refused
|
|
141
|
+
instead, with `security unlock-keychain` as the fix. A plaintext copy written
|
|
142
|
+
there would outlive the lock. `TokenStore.delete` clears both stores, so a file
|
|
143
|
+
token written before a host had a keyring cannot outlive the logout.
|
|
144
|
+
|
|
145
|
+
## A service authenticates as itself, never as a person
|
|
146
|
+
|
|
147
|
+
With `ACME_CLIENT_SECRET` set, `Product.token_source` authenticates as the
|
|
148
|
+
config file's `client_id` with the client-credentials grant. The variable is
|
|
149
|
+
`<NAME>_CLIENT_SECRET` unless `Product.secret_variable` names another. The
|
|
150
|
+
secret stays in the environment, because `auth status` prints the file.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
source = PRODUCT.token_source(resolved, service_scopes=('acme.items.read',))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
A service's token carries only the scopes its command names, so a command
|
|
157
|
+
naming none is refused before any request. Nothing is stored and no lock is
|
|
158
|
+
taken: the grant issues no refresh token. Name the service's client so the API
|
|
159
|
+
can tell it from a person's, such as `acme-svc-scheduler`.
|
|
160
|
+
|
|
161
|
+
## A broker's token command replaces the login
|
|
162
|
+
|
|
163
|
+
A workload whose broker hands it short-lived tokens names the broker's command
|
|
164
|
+
in the file:
|
|
165
|
+
|
|
166
|
+
```toml
|
|
167
|
+
api = "https://acme.example.com"
|
|
168
|
+
token_command = ["broker", "token", "--audience", "acme"]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The command prints an RFC 6749 § 5.1 token response. Every request carries its
|
|
172
|
+
`access_token`, and the command runs again once `expires_in` lapses.
|
|
173
|
+
|
|
174
|
+
## Linux and macOS
|
|
175
|
+
|
|
176
|
+
The refresh lock is `fcntl.flock`, so the package runs on Linux and macOS.
|
|
177
|
+
Releases are cut only from a commit whose suite passes on both, on Python 3.11
|
|
178
|
+
and 3.13.
|
|
179
|
+
|
|
180
|
+
## What it deliberately does not do
|
|
181
|
+
|
|
182
|
+
It is not a general OIDC client. It has no ID token verification, no
|
|
183
|
+
back-channel logout, no DPoP and no mTLS-bound tokens. An API that verifies
|
|
184
|
+
tokens against the provider's JWKS, as pyapiauth does, needs none of them.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyclilogin"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Device-grant login for Python CLIs: one config file, a keychain token store with a visible fallback, and a refresh serialized across processes"
|
|
5
|
+
authors = [{ name = "Chris Birch", email = "datapointchris@gmail.com" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
keywords = ["oauth2", "oidc", "device-authorization-grant", "cli", "keyring", "login"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: MacOS",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Topic :: Security",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# 3.11 is what `tomllib` and `enum.StrEnum` require, and every dependency
|
|
23
|
+
# supports it. Raising the floor excludes callers.
|
|
24
|
+
requires-python = ">=3.11"
|
|
25
|
+
|
|
26
|
+
# Each one is on the credential path, so each earns its place: httpx2 for the
|
|
27
|
+
# five requests the login makes, and keyring for the OS keychain, the Python
|
|
28
|
+
# counterpart of the go-keyring goclilogin uses.
|
|
29
|
+
dependencies = [
|
|
30
|
+
"httpx2>=2.9.1",
|
|
31
|
+
"keyring>=25",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
typer = ["typer>=0.15.0"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Repository = "https://github.com/datapointchris/pyclilogin"
|
|
39
|
+
Issues = "https://github.com/datapointchris/pyclilogin/issues"
|
|
40
|
+
Changelog = "https://github.com/datapointchris/pyclilogin/releases"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"bandit>=1.7.8",
|
|
45
|
+
"mypy>=1.10.0",
|
|
46
|
+
"pre-commit>=4.3.0",
|
|
47
|
+
"pytest>=8.0.0",
|
|
48
|
+
"ruff==0.17.1",
|
|
49
|
+
"typer>=0.15.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------- Tool Configurations ---------- #
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
[tool.bandit]
|
|
57
|
+
exclude_dirs = [".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "tests"]
|
|
58
|
+
skips = ["B404", "B603"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
[tool.codespell]
|
|
62
|
+
skip = '*.lock'
|
|
63
|
+
check-filenames = true
|
|
64
|
+
check-hidden = true
|
|
65
|
+
builtin = "clear,rare"
|
|
66
|
+
uri-ignore-words-list = "*"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
pretty = true
|
|
71
|
+
ignore_missing_imports = true
|
|
72
|
+
check_untyped_defs = false
|
|
73
|
+
warn_return_any = false
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
minversion = "8.0"
|
|
77
|
+
testpaths = ["tests"]
|
|
78
|
+
verbosity_assertions = 2
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
[tool.refurb]
|
|
82
|
+
enable_all = true
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
[tool.ruff]
|
|
86
|
+
line-length = 140
|
|
87
|
+
exclude = [
|
|
88
|
+
".git",
|
|
89
|
+
"__pycache__",
|
|
90
|
+
".mypy_cache",
|
|
91
|
+
".ruff_cache",
|
|
92
|
+
".vscode",
|
|
93
|
+
".venv",
|
|
94
|
+
"build",
|
|
95
|
+
"dist",
|
|
96
|
+
]
|
|
97
|
+
extend-exclude = ["*.md"]
|
|
98
|
+
|
|
99
|
+
[tool.ruff.format]
|
|
100
|
+
quote-style = "single"
|
|
101
|
+
indent-style = "space"
|
|
102
|
+
skip-magic-trailing-comma = false
|
|
103
|
+
line-ending = "auto"
|
|
104
|
+
docstring-code-format = true
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint]
|
|
107
|
+
select = [
|
|
108
|
+
"E", # pycodestyle errors
|
|
109
|
+
"F", # pyflakes
|
|
110
|
+
"UP", # pyupgrade
|
|
111
|
+
"B", # flake8-bugbear
|
|
112
|
+
"SIM", # flake8-simplify
|
|
113
|
+
"I", # isort
|
|
114
|
+
"ICN", # flake8-import-conventions
|
|
115
|
+
# flake8-bandit by rule, never as the family. Most of what the family
|
|
116
|
+
# reports is a subprocess call made on purpose, and silencing that takes an
|
|
117
|
+
# ignore list that grows with every project.
|
|
118
|
+
"S102", # exec
|
|
119
|
+
"S113", # an HTTP request with no timeout, which can hang forever
|
|
120
|
+
"S301", # pickle, which runs code from the bytes it loads
|
|
121
|
+
"S307", # eval
|
|
122
|
+
"S324", # md5 or sha1 used for security
|
|
123
|
+
"S501", # TLS certificate verification turned off
|
|
124
|
+
]
|
|
125
|
+
ignore = [
|
|
126
|
+
"SIM108", # ternary operator — often less readable
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
[tool.ruff.lint.isort]
|
|
130
|
+
force-single-line = true
|
|
131
|
+
|
|
132
|
+
[tool.ruff.lint.per-file-ignores]
|
|
133
|
+
"__init__.py" = ["F401"]
|
|
134
|
+
|
|
135
|
+
[tool.ruff.lint.flake8-import-conventions]
|
|
136
|
+
banned-from = ["datetime", "pyspark.sql.functions", "pyspark.sql.types"]
|
|
137
|
+
|
|
138
|
+
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
|
|
139
|
+
datetime = "dt"
|
|
140
|
+
sqlalchemy = "sa"
|
|
141
|
+
"pyspark.sql.functions" = "sf"
|
|
142
|
+
"pyspark.sql.types" = "st"
|
|
143
|
+
dataclasses = "dc"
|
|
144
|
+
|
|
145
|
+
[tool.semantic_release]
|
|
146
|
+
# A feature release on 0.x stays on 0.x. python-semantic-release v10 defaults
|
|
147
|
+
# this to false, which makes the next release of a 0.x project 1.0.0.
|
|
148
|
+
allow_zero_version = true
|
|
149
|
+
# 1.0 is a compatibility promise, so a breaking change on 0.x is a minor bump.
|
|
150
|
+
# Removing this key is how the project reaches 1.0.
|
|
151
|
+
major_on_zero = false
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------- Build System ---------- #
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
[tool.pyright]
|
|
158
|
+
typeCheckingMode = "standard"
|
|
159
|
+
analyzeUnannotatedFunctions = true # or pylance stops analyzing in vscode
|
|
160
|
+
|
|
161
|
+
[tool.hatch.version]
|
|
162
|
+
source = "uv-dynamic-versioning"
|
|
163
|
+
|
|
164
|
+
[tool.uv-dynamic-versioning]
|
|
165
|
+
style = "semver"
|
|
166
|
+
|
|
167
|
+
# A new commit or tag changes the version, so it has to invalidate uv's cache.
|
|
168
|
+
[tool.uv]
|
|
169
|
+
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
|
|
170
|
+
|
|
171
|
+
# The published sdist holds the package and its metadata, and none of the
|
|
172
|
+
# repo's tooling or tests. hatchling's default takes the whole tree.
|
|
173
|
+
[tool.hatch.build.targets.sdist]
|
|
174
|
+
only-include = ["src"]
|
|
175
|
+
|
|
176
|
+
[tool.forge]
|
|
177
|
+
# Keys the shared toolchain standard owns, written by its generator.
|
|
178
|
+
# Dropping one from the template removes it here on the next sync; a key absent
|
|
179
|
+
# from this list belongs to the project and is never touched. Do not hand-edit.
|
|
180
|
+
managed = [
|
|
181
|
+
["ruff", "line-length"],
|
|
182
|
+
["ruff", "extend-exclude"],
|
|
183
|
+
["ruff", "format", "quote-style"],
|
|
184
|
+
["ruff", "format", "indent-style"],
|
|
185
|
+
["ruff", "format", "skip-magic-trailing-comma"],
|
|
186
|
+
["ruff", "format", "line-ending"],
|
|
187
|
+
["ruff", "format", "docstring-code-format"],
|
|
188
|
+
["ruff", "lint", "select"],
|
|
189
|
+
["ruff", "lint", "ignore"],
|
|
190
|
+
["ruff", "lint", "isort", "force-single-line"],
|
|
191
|
+
["ruff", "lint", "flake8-import-conventions", "banned-from"],
|
|
192
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "datetime"],
|
|
193
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "dataclasses"],
|
|
194
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "sqlalchemy"],
|
|
195
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "pyspark.sql.functions"],
|
|
196
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "pyspark.sql.types"],
|
|
197
|
+
["ruff", "lint", "per-file-ignores", "__init__.py"],
|
|
198
|
+
["mypy", "pretty"],
|
|
199
|
+
["mypy", "ignore_missing_imports"],
|
|
200
|
+
["mypy", "check_untyped_defs"],
|
|
201
|
+
["mypy", "warn_return_any"],
|
|
202
|
+
["pyright", "typeCheckingMode"],
|
|
203
|
+
["pyright", "analyzeUnannotatedFunctions"],
|
|
204
|
+
["codespell", "check-filenames"],
|
|
205
|
+
["codespell", "check-hidden"],
|
|
206
|
+
["codespell", "builtin"],
|
|
207
|
+
["codespell", "uri-ignore-words-list"],
|
|
208
|
+
["pytest", "ini_options", "verbosity_assertions"],
|
|
209
|
+
["pytest", "ini_options", "minversion"],
|
|
210
|
+
["pytest", "ini_options", "testpaths"],
|
|
211
|
+
]
|
|
212
|
+
# Dependencies held at the version the pre-commit hook runs, in every group
|
|
213
|
+
# and extra that names them, so a local run and CI cannot disagree.
|
|
214
|
+
pinned = ["ruff"]
|
|
215
|
+
|
|
216
|
+
[build-system]
|
|
217
|
+
requires = ["hatchling", "uv-dynamic-versioning"]
|
|
218
|
+
build-backend = "hatchling.build"
|