pavilion 0.1.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.
- pavilion-0.1.0/PKG-INFO +112 -0
- pavilion-0.1.0/README.md +98 -0
- pavilion-0.1.0/pyproject.toml +32 -0
- pavilion-0.1.0/pyproject.toml.orig +31 -0
- pavilion-0.1.0/src/pavilion/__init__.py +5 -0
- pavilion-0.1.0/src/pavilion/auth/__init__.py +1 -0
- pavilion-0.1.0/src/pavilion/auth/cli.py +173 -0
- pavilion-0.1.0/src/pavilion/auth/config.py +77 -0
- pavilion-0.1.0/src/pavilion/auth/scaffold.py +121 -0
- pavilion-0.1.0/src/pavilion/auth/templates/__init__.py.jinja +87 -0
- pavilion-0.1.0/src/pavilion/auth/templates/cookies.py.jinja +59 -0
- pavilion-0.1.0/src/pavilion/auth/templates/passwords.py.jinja +34 -0
- pavilion-0.1.0/src/pavilion/auth/templates/service.py.jinja +123 -0
- pavilion-0.1.0/src/pavilion/auth/templates/tokens.py.jinja +120 -0
- pavilion-0.1.0/src/pavilion/auth/ttl.py +44 -0
- pavilion-0.1.0/src/pavilion/cli.py +15 -0
- pavilion-0.1.0/src/pavilion/compose/__init__.py +1 -0
- pavilion-0.1.0/src/pavilion/compose/cli.py +99 -0
- pavilion-0.1.0/src/pavilion/compose/file.py +89 -0
- pavilion-0.1.0/src/pavilion/compose/services/__init__.py +13 -0
- pavilion-0.1.0/src/pavilion/compose/services/base.py +24 -0
- pavilion-0.1.0/src/pavilion/compose/services/postgres.py +37 -0
- pavilion-0.1.0/src/pavilion/compose/services/redis.py +27 -0
- pavilion-0.1.0/src/pavilion/keys/__init__.py +1 -0
- pavilion-0.1.0/src/pavilion/keys/cli.py +96 -0
- pavilion-0.1.0/src/pavilion/keys/generate.py +197 -0
- pavilion-0.1.0/src/pavilion/ui.py +47 -0
pavilion-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pavilion
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: laconal
|
|
6
|
+
Author-email: laconal <asliddin.forwork@gmail.com>
|
|
7
|
+
Requires-Dist: cryptography>=50.0.2
|
|
8
|
+
Requires-Dist: jinja2>=3.1.6
|
|
9
|
+
Requires-Dist: questionary>=2.1.1
|
|
10
|
+
Requires-Dist: ruamel-yaml>=0.19.1
|
|
11
|
+
Requires-Dist: typer>=0.27.2
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# pavilion
|
|
16
|
+
|
|
17
|
+
A project scaffolder built with Typer.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
uv tool install . # or: uv run pavilion ...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pavilion add service list # show available services
|
|
29
|
+
pavilion add service redis # add redis to the compose file in the current dir
|
|
30
|
+
pavilion add service postgres # pick a version with the arrow keys
|
|
31
|
+
pavilion add service postgres --version 17 # non-interactive
|
|
32
|
+
pavilion add service redis -f infra/compose.yaml
|
|
33
|
+
pavilion add service redis --force # overwrite an existing redis service
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Pavilion edits the first of `compose.yaml`, `compose.yml`, `docker-compose.yaml` or
|
|
37
|
+
`docker-compose.yml` it finds, or creates `docker-compose.yml`. Existing comments and
|
|
38
|
+
ordering are preserved.
|
|
39
|
+
|
|
40
|
+
Available services: `redis` (8, 7) and `postgres` (18, 17, 16, 15). Without `--version`,
|
|
41
|
+
pavilion asks interactively; when there's no terminal (CI, pipes) it uses the newest.
|
|
42
|
+
|
|
43
|
+
Postgres defaults (`postgres` / `postgres`, database `app`) can be overridden with
|
|
44
|
+
`POSTGRES_USER`, `POSTGRES_PASSWORD` and `POSTGRES_DB` in a `.env` file.
|
|
45
|
+
|
|
46
|
+
### Keys
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pavilion add keys # pick an algorithm with the arrow keys
|
|
50
|
+
pavilion add keys ES256 # RS256 | ES256 | EdDSA (case-insensitive)
|
|
51
|
+
pavilion add keys EdDSA -d config/jwt # custom directory (default: secrets)
|
|
52
|
+
pavilion add keys RS256 --rsa-bits 4096 # RSA key size (default 2048, prompted if omitted)
|
|
53
|
+
pavilion add keys RS256 --force # replace an existing key pair
|
|
54
|
+
pavilion add keys EdDSA --refresh # refresh-token pair: private_refresh.pem, public_refresh.pem
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Writes `private.pem` (PKCS#8, mode 0600) and `public.pem` (SubjectPublicKeyInfo).
|
|
58
|
+
The directory gets its own `.gitignore` so keys are never committed by accident.
|
|
59
|
+
|
|
60
|
+
### Auth
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pavilion add auth # answer the prompts with the arrow keys
|
|
64
|
+
pavilion add auth --transport cookie --algorithm ES256 --hashing argon2
|
|
65
|
+
pavilion add auth --transport header --strategy symmetric
|
|
66
|
+
pavilion add auth --algorithm RS256 --rsa-bits 3072 --refresh-keys separate
|
|
67
|
+
pavilion add auth --access-ttl 30m --refresh-ttl 7d
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Generates a framework-agnostic `auth/` package (`-d` to change) built on JWT access and
|
|
71
|
+
refresh tokens. Their lifetimes default to 3h and 24h; enter them as `30m`, `3h`, `7d`
|
|
72
|
+
or `90s` (a bare number means hours), and the refresh TTL must be the longer one.
|
|
73
|
+
|
|
74
|
+
| File | Contents |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `tokens.py` | create/verify access and refresh tokens |
|
|
77
|
+
| `service.py` | `AuthService.login / refresh / logout / authenticate` |
|
|
78
|
+
| `passwords.py` | `hash_password` / `verify_password` (Argon2 or bcrypt) |
|
|
79
|
+
| `cookies.py` (`--transport cookie`) | HttpOnly cookie settings for `response.set_cookie(**cookie.kwargs)` |
|
|
80
|
+
|
|
81
|
+
With `--transport header` clients send `Authorization: Bearer <access token>`; with
|
|
82
|
+
`--transport cookie` the tokens are set as HttpOnly cookies (the refresh cookie is
|
|
83
|
+
`SameSite=Strict` and only sent to `/auth`).
|
|
84
|
+
|
|
85
|
+
Implement `UserRepository.get_by_username()` for your user model; see the generated
|
|
86
|
+
`__init__.py` for an example. Asymmetric signing reuses (or creates) the key pair in
|
|
87
|
+
`secrets/`; symmetric creates `secrets/jwt_secret`. With `--refresh-keys separate`,
|
|
88
|
+
refresh tokens are signed with their own keys/secret (`secrets/private_refresh.pem` and
|
|
89
|
+
`public_refresh.pem`, or `secrets/jwt_secret_refresh`), so a leaked
|
|
90
|
+
access key can't forge refresh tokens. The in-memory revoked-token store is for
|
|
91
|
+
development; back it with Redis or a database in production.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
Each `pavilion add ...` feature lives in its own folder under `src/pavilion/`, with its
|
|
96
|
+
command (`cli.py`) next to the logic behind it:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
cli.py root app; wires the feature commands together
|
|
100
|
+
ui.py shared menus, prompts and error exits
|
|
101
|
+
compose/ add service (file.py edits the compose file; services/ has one module per service)
|
|
102
|
+
keys/ add keys (generate.py)
|
|
103
|
+
auth/ add auth (config.py, scaffold.py, templates/)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
To add a compose service, create `compose/services/<name>.py` with a `ServiceSpec` and
|
|
107
|
+
register it in `compose/services/__init__.py`. Tests mirror this layout under `tests/`.
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
uv run pytest
|
|
112
|
+
```
|
pavilion-0.1.0/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# pavilion
|
|
2
|
+
|
|
3
|
+
A project scaffolder built with Typer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uv tool install . # or: uv run pavilion ...
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pavilion add service list # show available services
|
|
15
|
+
pavilion add service redis # add redis to the compose file in the current dir
|
|
16
|
+
pavilion add service postgres # pick a version with the arrow keys
|
|
17
|
+
pavilion add service postgres --version 17 # non-interactive
|
|
18
|
+
pavilion add service redis -f infra/compose.yaml
|
|
19
|
+
pavilion add service redis --force # overwrite an existing redis service
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Pavilion edits the first of `compose.yaml`, `compose.yml`, `docker-compose.yaml` or
|
|
23
|
+
`docker-compose.yml` it finds, or creates `docker-compose.yml`. Existing comments and
|
|
24
|
+
ordering are preserved.
|
|
25
|
+
|
|
26
|
+
Available services: `redis` (8, 7) and `postgres` (18, 17, 16, 15). Without `--version`,
|
|
27
|
+
pavilion asks interactively; when there's no terminal (CI, pipes) it uses the newest.
|
|
28
|
+
|
|
29
|
+
Postgres defaults (`postgres` / `postgres`, database `app`) can be overridden with
|
|
30
|
+
`POSTGRES_USER`, `POSTGRES_PASSWORD` and `POSTGRES_DB` in a `.env` file.
|
|
31
|
+
|
|
32
|
+
### Keys
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pavilion add keys # pick an algorithm with the arrow keys
|
|
36
|
+
pavilion add keys ES256 # RS256 | ES256 | EdDSA (case-insensitive)
|
|
37
|
+
pavilion add keys EdDSA -d config/jwt # custom directory (default: secrets)
|
|
38
|
+
pavilion add keys RS256 --rsa-bits 4096 # RSA key size (default 2048, prompted if omitted)
|
|
39
|
+
pavilion add keys RS256 --force # replace an existing key pair
|
|
40
|
+
pavilion add keys EdDSA --refresh # refresh-token pair: private_refresh.pem, public_refresh.pem
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Writes `private.pem` (PKCS#8, mode 0600) and `public.pem` (SubjectPublicKeyInfo).
|
|
44
|
+
The directory gets its own `.gitignore` so keys are never committed by accident.
|
|
45
|
+
|
|
46
|
+
### Auth
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pavilion add auth # answer the prompts with the arrow keys
|
|
50
|
+
pavilion add auth --transport cookie --algorithm ES256 --hashing argon2
|
|
51
|
+
pavilion add auth --transport header --strategy symmetric
|
|
52
|
+
pavilion add auth --algorithm RS256 --rsa-bits 3072 --refresh-keys separate
|
|
53
|
+
pavilion add auth --access-ttl 30m --refresh-ttl 7d
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Generates a framework-agnostic `auth/` package (`-d` to change) built on JWT access and
|
|
57
|
+
refresh tokens. Their lifetimes default to 3h and 24h; enter them as `30m`, `3h`, `7d`
|
|
58
|
+
or `90s` (a bare number means hours), and the refresh TTL must be the longer one.
|
|
59
|
+
|
|
60
|
+
| File | Contents |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `tokens.py` | create/verify access and refresh tokens |
|
|
63
|
+
| `service.py` | `AuthService.login / refresh / logout / authenticate` |
|
|
64
|
+
| `passwords.py` | `hash_password` / `verify_password` (Argon2 or bcrypt) |
|
|
65
|
+
| `cookies.py` (`--transport cookie`) | HttpOnly cookie settings for `response.set_cookie(**cookie.kwargs)` |
|
|
66
|
+
|
|
67
|
+
With `--transport header` clients send `Authorization: Bearer <access token>`; with
|
|
68
|
+
`--transport cookie` the tokens are set as HttpOnly cookies (the refresh cookie is
|
|
69
|
+
`SameSite=Strict` and only sent to `/auth`).
|
|
70
|
+
|
|
71
|
+
Implement `UserRepository.get_by_username()` for your user model; see the generated
|
|
72
|
+
`__init__.py` for an example. Asymmetric signing reuses (or creates) the key pair in
|
|
73
|
+
`secrets/`; symmetric creates `secrets/jwt_secret`. With `--refresh-keys separate`,
|
|
74
|
+
refresh tokens are signed with their own keys/secret (`secrets/private_refresh.pem` and
|
|
75
|
+
`public_refresh.pem`, or `secrets/jwt_secret_refresh`), so a leaked
|
|
76
|
+
access key can't forge refresh tokens. The in-memory revoked-token store is for
|
|
77
|
+
development; back it with Redis or a database in production.
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
Each `pavilion add ...` feature lives in its own folder under `src/pavilion/`, with its
|
|
82
|
+
command (`cli.py`) next to the logic behind it:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
cli.py root app; wires the feature commands together
|
|
86
|
+
ui.py shared menus, prompts and error exits
|
|
87
|
+
compose/ add service (file.py edits the compose file; services/ has one module per service)
|
|
88
|
+
keys/ add keys (generate.py)
|
|
89
|
+
auth/ add auth (config.py, scaffold.py, templates/)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
To add a compose service, create `compose/services/<name>.py` with a `ServiceSpec` and
|
|
93
|
+
register it in `compose/services/__init__.py`. Tests mirror this layout under `tests/`.
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
uv run pytest
|
|
98
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pavilion"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cryptography>=50.0.2",
|
|
9
|
+
"jinja2>=3.1.6",
|
|
10
|
+
"questionary>=2.1.1",
|
|
11
|
+
"ruamel-yaml>=0.19.1",
|
|
12
|
+
"typer>=0.27.2",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[[project.authors]]
|
|
16
|
+
name = "laconal"
|
|
17
|
+
email = "asliddin.forwork@gmail.com"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
pavilion = "pavilion:main"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"argon2-cffi>=25.1.0",
|
|
29
|
+
"bcrypt>=5.0.0",
|
|
30
|
+
"pyjwt[crypto]>=2.15.1",
|
|
31
|
+
"pytest>=9.1.1",
|
|
32
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pavilion"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "laconal", email = "asliddin.forwork@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cryptography>=50.0.2",
|
|
12
|
+
"jinja2>=3.1.6",
|
|
13
|
+
"questionary>=2.1.1",
|
|
14
|
+
"ruamel-yaml>=0.19.1",
|
|
15
|
+
"typer>=0.27.2",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
pavilion = "pavilion:main"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"argon2-cffi>=25.1.0",
|
|
28
|
+
"bcrypt>=5.0.0",
|
|
29
|
+
"pyjwt[crypto]>=2.15.1",
|
|
30
|
+
"pytest>=9.1.1",
|
|
31
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Auth package scaffolding (`pavilion add auth`)."""
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""`pavilion add auth`"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pavilion import ui
|
|
9
|
+
from pavilion.auth.config import AuthConfig, Hashing, RefreshKeys, Strategy, Transport
|
|
10
|
+
from pavilion.auth.scaffold import AuthFilesExistError, key_pairs, scaffold_auth
|
|
11
|
+
from pavilion.auth.ttl import DEFAULT_ACCESS_TTL, DEFAULT_REFRESH_TTL, TTL, validate_ttls
|
|
12
|
+
from pavilion.keys.cli import RsaBitsOption, check_rsa_bits, choose_algorithm, choose_rsa_bits
|
|
13
|
+
from pavilion.keys.generate import (
|
|
14
|
+
DEFAULT_SECRETS_DIR,
|
|
15
|
+
Algorithm,
|
|
16
|
+
KeyMismatchError,
|
|
17
|
+
KeyPair,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
REFRESH_KEY_LABELS = {
|
|
21
|
+
Strategy.ASYMMETRIC: "Separate key pair (a leaked access key can't forge refresh tokens)",
|
|
22
|
+
Strategy.SYMMETRIC: "Separate secret (a leaked access secret can't forge refresh tokens)",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_ttl(text: str) -> TTL:
|
|
28
|
+
try:
|
|
29
|
+
return TTL.parse(text)
|
|
30
|
+
except ValueError as e:
|
|
31
|
+
raise typer.BadParameter(str(e)) from None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ttl_option(flag: str, default: TTL, token: str) -> typer.models.OptionInfo:
|
|
35
|
+
return typer.Option(
|
|
36
|
+
flag,
|
|
37
|
+
parser=_parse_ttl,
|
|
38
|
+
metavar="DURATION",
|
|
39
|
+
help=f"{token} token lifetime, e.g. 30m, 3h, 7d (default {default}). Prompts if omitted.",
|
|
40
|
+
show_default=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _choose_ttl(message: str, default: TTL, longer_than: TTL | None = None) -> TTL:
|
|
45
|
+
def validate(answer: str) -> bool | str:
|
|
46
|
+
try:
|
|
47
|
+
ttl = TTL.parse(answer)
|
|
48
|
+
if longer_than is not None:
|
|
49
|
+
validate_ttls(longer_than, ttl)
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
return str(e)
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
return TTL.parse(ui.text(message, str(default), validate))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def add_auth(
|
|
58
|
+
transport: Annotated[
|
|
59
|
+
Transport | None,
|
|
60
|
+
typer.Option(help="How tokens travel: Authorization header or cookies.", show_default=False),
|
|
61
|
+
] = None,
|
|
62
|
+
strategy: Annotated[
|
|
63
|
+
Strategy | None, typer.Option(help="Token signing strategy.", show_default=False)
|
|
64
|
+
] = None,
|
|
65
|
+
algorithm: Annotated[
|
|
66
|
+
Algorithm | None,
|
|
67
|
+
typer.Option(
|
|
68
|
+
case_sensitive=False, help="Asymmetric JWT algorithm.", show_default=False
|
|
69
|
+
),
|
|
70
|
+
] = None,
|
|
71
|
+
rsa_bits: RsaBitsOption = None,
|
|
72
|
+
refresh_keys: Annotated[
|
|
73
|
+
RefreshKeys | None,
|
|
74
|
+
typer.Option(help="Sign refresh tokens with their own keys.", show_default=False),
|
|
75
|
+
] = None,
|
|
76
|
+
access_ttl: Annotated[
|
|
77
|
+
TTL | None, _ttl_option("--access-ttl", DEFAULT_ACCESS_TTL, "Access")
|
|
78
|
+
] = None,
|
|
79
|
+
refresh_ttl: Annotated[
|
|
80
|
+
TTL | None, _ttl_option("--refresh-ttl", DEFAULT_REFRESH_TTL, "Refresh")
|
|
81
|
+
] = None,
|
|
82
|
+
hashing: Annotated[
|
|
83
|
+
Hashing | None, typer.Option(help="Password hashing.", show_default=False)
|
|
84
|
+
] = None,
|
|
85
|
+
directory: Annotated[
|
|
86
|
+
Path, typer.Option("--dir", "-d", help="Directory for the generated package.")
|
|
87
|
+
] = Path("auth"),
|
|
88
|
+
force: Annotated[
|
|
89
|
+
bool, typer.Option("--force", help="Overwrite existing auth files.")
|
|
90
|
+
] = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Generate an auth package: JWT access/refresh tokens, password hashing, login/refresh/logout.
|
|
93
|
+
|
|
94
|
+
Prompts for any choice not given as an option.
|
|
95
|
+
"""
|
|
96
|
+
transport = transport or ui.select(
|
|
97
|
+
"Authentication transport:",
|
|
98
|
+
{
|
|
99
|
+
Transport.HEADER: "JWT in Authorization header (Bearer token)",
|
|
100
|
+
Transport.COOKIE: "JWT in cookies (HttpOnly)",
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
strategy = strategy or ui.select(
|
|
104
|
+
"Token/signing strategy:",
|
|
105
|
+
{
|
|
106
|
+
Strategy.ASYMMETRIC: "Asymmetric (private key signs, public key verifies)",
|
|
107
|
+
Strategy.SYMMETRIC: "Symmetric (HS256, one shared secret)",
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
if strategy is Strategy.SYMMETRIC and algorithm:
|
|
111
|
+
raise typer.BadParameter("only applies to --strategy asymmetric", param_hint="--algorithm")
|
|
112
|
+
if strategy is Strategy.ASYMMETRIC:
|
|
113
|
+
algorithm = algorithm or choose_algorithm("Algorithm:", default=Algorithm.EdDSA)
|
|
114
|
+
check_rsa_bits(rsa_bits, algorithm)
|
|
115
|
+
refresh_keys = refresh_keys or ui.select(
|
|
116
|
+
"Refresh token keys:",
|
|
117
|
+
{
|
|
118
|
+
RefreshKeys.SHARED: "Same as access tokens",
|
|
119
|
+
RefreshKeys.SEPARATE: REFRESH_KEY_LABELS[strategy],
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
if algorithm is Algorithm.RS256 and rsa_bits is None:
|
|
123
|
+
# Existing keys get reused, so only ask for a size when new ones will be made.
|
|
124
|
+
separate = refresh_keys is RefreshKeys.SEPARATE
|
|
125
|
+
if any(not keys.private.exists() for keys in key_pairs(DEFAULT_SECRETS_DIR, separate)):
|
|
126
|
+
rsa_bits = choose_rsa_bits()
|
|
127
|
+
access_ttl = access_ttl or _choose_ttl("Access token TTL:", DEFAULT_ACCESS_TTL)
|
|
128
|
+
refresh_ttl = refresh_ttl or _choose_ttl(
|
|
129
|
+
"Refresh token TTL:", DEFAULT_REFRESH_TTL, longer_than=access_ttl
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
validate_ttls(access_ttl, refresh_ttl)
|
|
133
|
+
except ValueError as e:
|
|
134
|
+
raise typer.BadParameter(str(e), param_hint="--access-ttl/--refresh-ttl") from None
|
|
135
|
+
hashing = hashing or ui.select(
|
|
136
|
+
"Password hashing:", {Hashing.ARGON2: "Argon2", Hashing.BCRYPT: "bcrypt"}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
config = AuthConfig(
|
|
140
|
+
transport=transport,
|
|
141
|
+
hashing=hashing,
|
|
142
|
+
strategy=strategy,
|
|
143
|
+
algorithm=algorithm,
|
|
144
|
+
refresh_keys=refresh_keys,
|
|
145
|
+
access_ttl=access_ttl,
|
|
146
|
+
refresh_ttl=refresh_ttl,
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
result = scaffold_auth(
|
|
150
|
+
config, directory, DEFAULT_SECRETS_DIR, rsa_key_size=rsa_bits, force=force
|
|
151
|
+
)
|
|
152
|
+
except AuthFilesExistError as e:
|
|
153
|
+
raise ui.fail(
|
|
154
|
+
f"Auth files already exist: {', '.join(map(str, e.paths))}. Use --force to overwrite."
|
|
155
|
+
)
|
|
156
|
+
except KeyMismatchError as e:
|
|
157
|
+
size = f" --rsa-bits {rsa_bits}" if rsa_bits else ""
|
|
158
|
+
target = " --refresh" if e.keys == KeyPair.in_dir(DEFAULT_SECRETS_DIR, refresh=True) else ""
|
|
159
|
+
raise ui.fail(
|
|
160
|
+
f"{e}.\nPick a matching option, or replace the keys with "
|
|
161
|
+
f"`pavilion add keys {algorithm}{size}{target} --force`.",
|
|
162
|
+
color=typer.colors.RED,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
typer.secho(f"Generated {directory}/ ({config.describe()}):", fg=typer.colors.GREEN)
|
|
166
|
+
for path in result.files:
|
|
167
|
+
typer.echo(f" {path}")
|
|
168
|
+
for path in result.removed:
|
|
169
|
+
typer.echo(f" {path} (removed, not used by this configuration)")
|
|
170
|
+
for note in result.secrets_notes:
|
|
171
|
+
typer.echo(note)
|
|
172
|
+
deps = " ".join(f"'{d}'" if "[" in d else d for d in config.dependencies)
|
|
173
|
+
typer.echo(f"\nNext: uv add {deps}")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The choices behind a generated auth package."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
from pavilion.auth.ttl import DEFAULT_ACCESS_TTL, DEFAULT_REFRESH_TTL, TTL
|
|
7
|
+
from pavilion.keys.generate import Algorithm
|
|
8
|
+
|
|
9
|
+
SYMMETRIC_ALGORITHM = "HS256"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Transport(StrEnum):
|
|
13
|
+
"""How the access/refresh tokens travel between client and server."""
|
|
14
|
+
|
|
15
|
+
HEADER = "header"
|
|
16
|
+
COOKIE = "cookie"
|
|
17
|
+
|
|
18
|
+
class Strategy(StrEnum):
|
|
19
|
+
ASYMMETRIC = "asymmetric"
|
|
20
|
+
SYMMETRIC = "symmetric"
|
|
21
|
+
|
|
22
|
+
class Hashing(StrEnum):
|
|
23
|
+
ARGON2 = "argon2"
|
|
24
|
+
BCRYPT = "bcrypt"
|
|
25
|
+
|
|
26
|
+
class RefreshKeys(StrEnum):
|
|
27
|
+
SHARED = "shared"
|
|
28
|
+
SEPARATE = "separate"
|
|
29
|
+
|
|
30
|
+
# Output file name -> template path (relative to auth/templates), for every transport.
|
|
31
|
+
COMMON_TEMPLATES = {
|
|
32
|
+
"__init__.py": "__init__.py.jinja",
|
|
33
|
+
"passwords.py": "passwords.py.jinja",
|
|
34
|
+
"tokens.py": "tokens.py.jinja",
|
|
35
|
+
"service.py": "service.py.jinja",
|
|
36
|
+
}
|
|
37
|
+
COOKIE_TEMPLATES = {"cookies.py": "cookies.py.jinja"}
|
|
38
|
+
# Every file pavilion may generate, so `--force` can remove ones a new config doesn't use.
|
|
39
|
+
ALL_OUTPUT_FILES = frozenset(COMMON_TEMPLATES) | frozenset(COOKIE_TEMPLATES)
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AuthConfig:
|
|
43
|
+
transport: Transport
|
|
44
|
+
strategy: Strategy
|
|
45
|
+
hashing: Hashing
|
|
46
|
+
algorithm: Algorithm | None = None # asymmetric only
|
|
47
|
+
refresh_keys: RefreshKeys = RefreshKeys.SHARED
|
|
48
|
+
access_ttl: TTL = DEFAULT_ACCESS_TTL
|
|
49
|
+
refresh_ttl: TTL = DEFAULT_REFRESH_TTL
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def separate_refresh_keys(self) -> bool:
|
|
53
|
+
return self.refresh_keys is RefreshKeys.SEPARATE
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def jwt_algorithm(self) -> str:
|
|
57
|
+
return str(self.algorithm) if self.strategy is Strategy.ASYMMETRIC else SYMMETRIC_ALGORITHM
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def templates(self) -> dict[str, str]:
|
|
61
|
+
"""Output file name -> template path (relative to auth/templates)."""
|
|
62
|
+
if self.transport is Transport.COOKIE:
|
|
63
|
+
return COMMON_TEMPLATES | COOKIE_TEMPLATES
|
|
64
|
+
return COMMON_TEMPLATES
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def dependencies(self) -> list[str]:
|
|
68
|
+
# Asymmetric algorithms need PyJWT's `cryptography` extra.
|
|
69
|
+
jwt = "pyjwt[crypto]" if self.strategy is Strategy.ASYMMETRIC else "pyjwt"
|
|
70
|
+
return [jwt, "argon2-cffi" if self.hashing is Hashing.ARGON2 else "bcrypt"]
|
|
71
|
+
|
|
72
|
+
def describe(self) -> str:
|
|
73
|
+
hashing = "Argon2" if self.hashing is Hashing.ARGON2 else "bcrypt"
|
|
74
|
+
via = "cookies" if self.transport is Transport.COOKIE else "header"
|
|
75
|
+
refresh = ", separate refresh keys" if self.separate_refresh_keys else ""
|
|
76
|
+
ttls = f"access {self.access_ttl} / refresh {self.refresh_ttl}"
|
|
77
|
+
return f"JWT {self.jwt_algorithm} via {via} ({self.strategy}{refresh}), {ttls}, {hashing}"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Rendering an auth package (tokens, cookies, password hashing, login/refresh/logout)."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from jinja2 import Environment, PackageLoader, StrictUndefined
|
|
7
|
+
|
|
8
|
+
from pavilion.auth.config import ALL_OUTPUT_FILES, AuthConfig, Strategy, Transport
|
|
9
|
+
from pavilion.keys.generate import (
|
|
10
|
+
Algorithm,
|
|
11
|
+
KeyPair,
|
|
12
|
+
check_keys,
|
|
13
|
+
ensure_keys,
|
|
14
|
+
ensure_secret,
|
|
15
|
+
read_rsa_key_size,
|
|
16
|
+
secret_path,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthFilesExistError(Exception):
|
|
21
|
+
def __init__(self, paths: list[Path]):
|
|
22
|
+
super().__init__(", ".join(map(str, paths)))
|
|
23
|
+
self.paths = paths
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AuthResult:
|
|
28
|
+
files: list[Path]
|
|
29
|
+
# Files from a previous run that this config doesn't use (deleted with --force).
|
|
30
|
+
removed: list[Path]
|
|
31
|
+
# Human-readable notes about the keys/secrets used: access first, then refresh.
|
|
32
|
+
secrets_notes: list[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_env = Environment(
|
|
36
|
+
loader=PackageLoader("pavilion.auth", "templates"),
|
|
37
|
+
undefined=StrictUndefined,
|
|
38
|
+
trim_blocks=True,
|
|
39
|
+
lstrip_blocks=True,
|
|
40
|
+
keep_trailing_newline=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def key_pairs(secrets_dir: Path, separate_refresh_keys: bool) -> list[KeyPair]:
|
|
45
|
+
"""Asymmetric key pairs used: access tokens first, then refresh tokens if separate."""
|
|
46
|
+
access = KeyPair.in_dir(secrets_dir)
|
|
47
|
+
if separate_refresh_keys:
|
|
48
|
+
return [access, KeyPair.in_dir(secrets_dir, refresh=True)]
|
|
49
|
+
return [access]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _prepare_secrets(
|
|
53
|
+
config: AuthConfig, secrets_dir: Path, rsa_key_size: int | None
|
|
54
|
+
) -> list[str]:
|
|
55
|
+
def verb(created: bool) -> str:
|
|
56
|
+
return "Generated" if created else "Using existing"
|
|
57
|
+
|
|
58
|
+
if config.strategy is Strategy.SYMMETRIC:
|
|
59
|
+
paths = [secret_path(secrets_dir)]
|
|
60
|
+
if config.separate_refresh_keys:
|
|
61
|
+
paths.append(secret_path(secrets_dir, refresh=True))
|
|
62
|
+
return [f"{verb(ensure_secret(path))} HS256 secret: {path}" for path in paths]
|
|
63
|
+
|
|
64
|
+
assert config.algorithm is not None
|
|
65
|
+
pairs = key_pairs(secrets_dir, config.separate_refresh_keys)
|
|
66
|
+
# Validate every pair first so a mismatch in one doesn't leave new keys behind.
|
|
67
|
+
for keys in pairs:
|
|
68
|
+
check_keys(keys, config.algorithm, rsa_key_size)
|
|
69
|
+
notes = []
|
|
70
|
+
for keys in pairs:
|
|
71
|
+
created = ensure_keys(keys, config.algorithm, rsa_key_size)
|
|
72
|
+
label = str(config.algorithm)
|
|
73
|
+
if config.algorithm is Algorithm.RS256:
|
|
74
|
+
label += f" ({read_rsa_key_size(keys.private)}-bit)"
|
|
75
|
+
notes.append(f"{verb(created)} {label} key pair: {keys.private}, {keys.public}")
|
|
76
|
+
return notes
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def scaffold_auth(
|
|
80
|
+
config: AuthConfig,
|
|
81
|
+
directory: Path,
|
|
82
|
+
secrets_dir: Path,
|
|
83
|
+
*,
|
|
84
|
+
rsa_key_size: int | None = None,
|
|
85
|
+
force: bool = False,
|
|
86
|
+
) -> AuthResult:
|
|
87
|
+
"""Render the auth package into `directory` and make sure signing material exists.
|
|
88
|
+
|
|
89
|
+
`rsa_key_size` applies to newly generated RS256 keys (default 2048); when given,
|
|
90
|
+
existing RS256 keys must match it.
|
|
91
|
+
|
|
92
|
+
Raises AuthFilesExistError or KeyMismatchError before writing anything.
|
|
93
|
+
"""
|
|
94
|
+
targets = {directory / name: template for name, template in config.templates.items()}
|
|
95
|
+
existing = sorted(p for name in ALL_OUTPUT_FILES if (p := directory / name).exists())
|
|
96
|
+
if existing and not force:
|
|
97
|
+
raise AuthFilesExistError(existing)
|
|
98
|
+
stale = [path for path in existing if path not in targets]
|
|
99
|
+
|
|
100
|
+
secrets_notes = _prepare_secrets(config, secrets_dir, rsa_key_size)
|
|
101
|
+
|
|
102
|
+
context = {
|
|
103
|
+
"cookie": config.transport is Transport.COOKIE,
|
|
104
|
+
"hashing": str(config.hashing),
|
|
105
|
+
"asymmetric": config.strategy is Strategy.ASYMMETRIC,
|
|
106
|
+
"algorithm": config.jwt_algorithm,
|
|
107
|
+
"separate_refresh_keys": config.separate_refresh_keys,
|
|
108
|
+
"access_ttl": config.access_ttl.python(),
|
|
109
|
+
"refresh_ttl": config.refresh_ttl.python(),
|
|
110
|
+
"access_keys": KeyPair.in_dir(secrets_dir),
|
|
111
|
+
"refresh_keys": KeyPair.in_dir(secrets_dir, refresh=True),
|
|
112
|
+
"access_secret": secret_path(secrets_dir).as_posix(),
|
|
113
|
+
"refresh_secret": secret_path(secrets_dir, refresh=True).as_posix(),
|
|
114
|
+
"package": directory.name,
|
|
115
|
+
}
|
|
116
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
for path, template in targets.items():
|
|
118
|
+
path.write_text(_env.get_template(template).render(context))
|
|
119
|
+
for path in stale:
|
|
120
|
+
path.unlink()
|
|
121
|
+
return AuthResult(files=list(targets), removed=stale, secrets_notes=secrets_notes)
|