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.
@@ -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
+ ```
@@ -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,5 @@
1
+ from pavilion.cli import app
2
+
3
+
4
+ def main() -> None:
5
+ app()
@@ -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)