pavilion 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- pavilion/auth/__init__.py +1 -0
- pavilion/auth/cli.py +173 -0
- pavilion/auth/config.py +77 -0
- pavilion/auth/scaffold.py +121 -0
- pavilion/auth/templates/__init__.py.jinja +87 -0
- pavilion/auth/templates/cookies.py.jinja +59 -0
- pavilion/auth/templates/passwords.py.jinja +34 -0
- pavilion/auth/templates/service.py.jinja +123 -0
- pavilion/auth/templates/tokens.py.jinja +120 -0
- pavilion/auth/ttl.py +44 -0
- pavilion/cli.py +15 -0
- pavilion/compose/__init__.py +1 -0
- pavilion/compose/cli.py +99 -0
- pavilion/compose/file.py +89 -0
- pavilion/compose/services/__init__.py +13 -0
- pavilion/compose/services/base.py +24 -0
- pavilion/compose/services/postgres.py +37 -0
- pavilion/compose/services/redis.py +27 -0
- pavilion/keys/__init__.py +1 -0
- pavilion/keys/cli.py +96 -0
- pavilion/keys/generate.py +197 -0
- pavilion/ui.py +47 -0
- pavilion-0.1.0.dist-info/METADATA +112 -0
- pavilion-0.1.0.dist-info/RECORD +27 -0
- pavilion-0.1.0.dist-info/WHEEL +4 -0
- pavilion-0.1.0.dist-info/entry_points.txt +3 -0
pavilion/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Auth package scaffolding (`pavilion add auth`)."""
|
pavilion/auth/cli.py
ADDED
|
@@ -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}")
|
pavilion/auth/config.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Authentication: JWT ({{ algorithm }}) via {{ "HttpOnly cookies" if cookie else "the Authorization header" }}, {{ "Argon2" if hashing == "argon2" else "bcrypt" }} password hashing.
|
|
2
|
+
|
|
3
|
+
Generated by `pavilion add auth`. Implement `UserRepository` for your user model, then:
|
|
4
|
+
|
|
5
|
+
{% if cookie %}
|
|
6
|
+
from {{ package }} import (
|
|
7
|
+
ACCESS_COOKIE,
|
|
8
|
+
REFRESH_COOKIE,
|
|
9
|
+
AuthService,
|
|
10
|
+
InMemoryRevokedTokenStore,
|
|
11
|
+
cleared_cookies,
|
|
12
|
+
token_cookies,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
auth = AuthService(users=MyUserRepository(), revoked_tokens=InMemoryRevokedTokenStore())
|
|
16
|
+
|
|
17
|
+
# login (and refresh): set both cookies on the response
|
|
18
|
+
pair = auth.login("alice", "password")
|
|
19
|
+
for cookie in token_cookies(pair):
|
|
20
|
+
response.set_cookie(**cookie.kwargs)
|
|
21
|
+
|
|
22
|
+
user_id = auth.authenticate(request.cookies[ACCESS_COOKIE])
|
|
23
|
+
pair = auth.refresh(request.cookies[REFRESH_COOKIE])
|
|
24
|
+
|
|
25
|
+
# logout: revoke, then clear both cookies
|
|
26
|
+
auth.logout(request.cookies[REFRESH_COOKIE])
|
|
27
|
+
for cookie in cleared_cookies():
|
|
28
|
+
response.set_cookie(**cookie.kwargs)
|
|
29
|
+
{% else %}
|
|
30
|
+
from {{ package }} import AuthService, InMemoryRevokedTokenStore
|
|
31
|
+
|
|
32
|
+
auth = AuthService(users=MyUserRepository(), revoked_tokens=InMemoryRevokedTokenStore())
|
|
33
|
+
pair = auth.login("alice", "password") # return to the client as JSON
|
|
34
|
+
user_id = auth.authenticate(access_token) # from "Authorization: Bearer <token>"
|
|
35
|
+
pair = auth.refresh(refresh_token)
|
|
36
|
+
auth.logout(refresh_token)
|
|
37
|
+
{% endif %}
|
|
38
|
+
|
|
39
|
+
Store new passwords with `hash_password()`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
{% if cookie %}
|
|
43
|
+
from .cookies import (
|
|
44
|
+
ACCESS_COOKIE,
|
|
45
|
+
REFRESH_COOKIE,
|
|
46
|
+
REFRESH_COOKIE_PATH,
|
|
47
|
+
Cookie,
|
|
48
|
+
cleared_cookies,
|
|
49
|
+
token_cookies,
|
|
50
|
+
)
|
|
51
|
+
{% endif %}
|
|
52
|
+
from .passwords import hash_password, verify_password
|
|
53
|
+
from .service import (
|
|
54
|
+
AuthError,
|
|
55
|
+
AuthService,
|
|
56
|
+
InMemoryRevokedTokenStore,
|
|
57
|
+
RevokedTokenStore,
|
|
58
|
+
TokenPair,
|
|
59
|
+
User,
|
|
60
|
+
UserRepository,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
{% if cookie %}
|
|
65
|
+
"ACCESS_COOKIE",
|
|
66
|
+
"REFRESH_COOKIE",
|
|
67
|
+
"REFRESH_COOKIE_PATH",
|
|
68
|
+
{% endif %}
|
|
69
|
+
"AuthError",
|
|
70
|
+
"AuthService",
|
|
71
|
+
{% if cookie %}
|
|
72
|
+
"Cookie",
|
|
73
|
+
{% endif %}
|
|
74
|
+
"InMemoryRevokedTokenStore",
|
|
75
|
+
"RevokedTokenStore",
|
|
76
|
+
"TokenPair",
|
|
77
|
+
"User",
|
|
78
|
+
"UserRepository",
|
|
79
|
+
{% if cookie %}
|
|
80
|
+
"cleared_cookies",
|
|
81
|
+
{% endif %}
|
|
82
|
+
"hash_password",
|
|
83
|
+
{% if cookie %}
|
|
84
|
+
"token_cookies",
|
|
85
|
+
{% endif %}
|
|
86
|
+
"verify_password",
|
|
87
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Sending access and refresh tokens to the browser as HttpOnly cookies.
|
|
2
|
+
|
|
3
|
+
Pass `cookie.kwargs` to your framework's `response.set_cookie(**cookie.kwargs)`
|
|
4
|
+
(Starlette/FastAPI, Flask and Django all accept these names), and read tokens back
|
|
5
|
+
from the request's cookies by ACCESS_COOKIE / REFRESH_COOKIE.
|
|
6
|
+
|
|
7
|
+
HttpOnly keeps tokens out of reach of JavaScript (and so of XSS). Browsers attach
|
|
8
|
+
cookies automatically, so SameSite is what stops cross-site requests from using them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from .service import TokenPair
|
|
15
|
+
from .tokens import ACCESS_TOKEN_TTL, REFRESH_TOKEN_TTL
|
|
16
|
+
|
|
17
|
+
ACCESS_COOKIE = "access_token"
|
|
18
|
+
REFRESH_COOKIE = "refresh_token"
|
|
19
|
+
# The refresh cookie is only sent to paths under this one: point it at the
|
|
20
|
+
# routes that call AuthService.refresh() and .logout().
|
|
21
|
+
REFRESH_COOKIE_PATH = "/auth"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Cookie:
|
|
26
|
+
key: str
|
|
27
|
+
value: str
|
|
28
|
+
max_age: int
|
|
29
|
+
path: str = "/"
|
|
30
|
+
httponly: bool = True
|
|
31
|
+
# Most browsers accept Secure cookies on http://localhost, so this also works locally.
|
|
32
|
+
secure: bool = True
|
|
33
|
+
samesite: Literal["lax", "strict"] = "lax"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def kwargs(self) -> dict[str, Any]:
|
|
37
|
+
return asdict(self)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _access_cookie(value: str, max_age: int) -> Cookie:
|
|
41
|
+
return Cookie(ACCESS_COOKIE, value, max_age)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _refresh_cookie(value: str, max_age: int) -> Cookie:
|
|
45
|
+
# Strict: never sent with cross-site requests, even top-level navigations.
|
|
46
|
+
return Cookie(REFRESH_COOKIE, value, max_age, path=REFRESH_COOKIE_PATH, samesite="strict")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def token_cookies(pair: TokenPair) -> list[Cookie]:
|
|
50
|
+
"""Cookies to set after login() or refresh(); they expire along with their tokens."""
|
|
51
|
+
return [
|
|
52
|
+
_access_cookie(pair.access_token, int(ACCESS_TOKEN_TTL.total_seconds())),
|
|
53
|
+
_refresh_cookie(pair.refresh_token, int(REFRESH_TOKEN_TTL.total_seconds())),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cleared_cookies() -> list[Cookie]:
|
|
58
|
+
"""Cookies to set after logout(): same names and paths, expired immediately."""
|
|
59
|
+
return [_access_cookie("", 0), _refresh_cookie("", 0)]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Password hashing with {{ "Argon2" if hashing == "argon2" else "bcrypt" }}."""
|
|
2
|
+
|
|
3
|
+
{% if hashing == "argon2" %}
|
|
4
|
+
from argon2 import PasswordHasher
|
|
5
|
+
from argon2.exceptions import InvalidHashError, VerificationError
|
|
6
|
+
|
|
7
|
+
# Defaults to RFC 9106's recommended (low-memory) parameters.
|
|
8
|
+
_hasher = PasswordHasher()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hash_password(password: str) -> str:
|
|
12
|
+
return _hasher.hash(password)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
16
|
+
try:
|
|
17
|
+
return _hasher.verify(password_hash, password)
|
|
18
|
+
except (VerificationError, InvalidHashError):
|
|
19
|
+
return False
|
|
20
|
+
{% else %}
|
|
21
|
+
import bcrypt
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hash_password(password: str) -> str:
|
|
25
|
+
# bcrypt rejects passwords over 72 bytes (ValueError); cap length at signup.
|
|
26
|
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
|
32
|
+
except ValueError: # malformed hash or over-long password
|
|
33
|
+
return False
|
|
34
|
+
{% endif %}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Login, refresh and logout with JWT access/refresh tokens."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from functools import cache
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
from . import tokens
|
|
9
|
+
from .passwords import hash_password, verify_password
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(Exception):
|
|
13
|
+
"""Invalid credentials, or an invalid, expired or revoked token."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class User(Protocol):
|
|
17
|
+
@property
|
|
18
|
+
def id(self) -> Any: ...
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def password_hash(self) -> str: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UserRepository(Protocol):
|
|
25
|
+
def get_by_username(self, username: str) -> User | None: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RevokedTokenStore(Protocol):
|
|
29
|
+
"""Remembers revoked refresh tokens (by `jti`) until they would have expired."""
|
|
30
|
+
|
|
31
|
+
def revoke(self, jti: str, expires_at: datetime) -> None: ...
|
|
32
|
+
|
|
33
|
+
def is_revoked(self, jti: str) -> bool: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InMemoryRevokedTokenStore:
|
|
37
|
+
"""Single-process store for development; use Redis or a database in production."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._revoked: dict[str, datetime] = {}
|
|
41
|
+
|
|
42
|
+
def revoke(self, jti: str, expires_at: datetime) -> None:
|
|
43
|
+
now = datetime.now(UTC)
|
|
44
|
+
# Expired tokens are rejected anyway, so there's no need to remember them.
|
|
45
|
+
self._revoked = {j: exp for j, exp in self._revoked.items() if exp > now}
|
|
46
|
+
self._revoked[jti] = expires_at
|
|
47
|
+
|
|
48
|
+
def is_revoked(self, jti: str) -> bool:
|
|
49
|
+
return jti in self._revoked
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class TokenPair:
|
|
54
|
+
access_token: str
|
|
55
|
+
refresh_token: str
|
|
56
|
+
{% if not cookie %}
|
|
57
|
+
token_type: str = "bearer"
|
|
58
|
+
{% endif %}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthService:
|
|
62
|
+
def __init__(self, users: UserRepository, revoked_tokens: RevokedTokenStore) -> None:
|
|
63
|
+
self.users = users
|
|
64
|
+
self.revoked_tokens = revoked_tokens
|
|
65
|
+
|
|
66
|
+
def login(self, username: str, password: str) -> TokenPair:
|
|
67
|
+
user = self.users.get_by_username(username)
|
|
68
|
+
if user is None:
|
|
69
|
+
# Hash anyway so response time doesn't reveal which usernames exist.
|
|
70
|
+
verify_password(password, _dummy_hash())
|
|
71
|
+
raise AuthError("Invalid username or password")
|
|
72
|
+
if not verify_password(password, user.password_hash):
|
|
73
|
+
raise AuthError("Invalid username or password")
|
|
74
|
+
return _issue_tokens(str(user.id))
|
|
75
|
+
|
|
76
|
+
def refresh(self, refresh_token: str) -> TokenPair:
|
|
77
|
+
"""Exchange a refresh token for a new pair; the old refresh token stops working."""
|
|
78
|
+
claims = self._decode_refresh_token(refresh_token)
|
|
79
|
+
self._revoke(claims)
|
|
80
|
+
return _issue_tokens(claims["sub"])
|
|
81
|
+
|
|
82
|
+
def logout(self, refresh_token: str) -> None:
|
|
83
|
+
"""Revoke the refresh token.
|
|
84
|
+
|
|
85
|
+
Access tokens are stateless and stay valid until they expire
|
|
86
|
+
(tokens.ACCESS_TOKEN_TTL), so keep that TTL short.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
claims = self._decode_refresh_token(refresh_token)
|
|
90
|
+
except AuthError:
|
|
91
|
+
return # already unusable
|
|
92
|
+
self._revoke(claims)
|
|
93
|
+
|
|
94
|
+
def authenticate(self, access_token: str) -> str:
|
|
95
|
+
"""Return the id of the user an access token was issued to."""
|
|
96
|
+
try:
|
|
97
|
+
return tokens.decode_token(access_token, "access")["sub"]
|
|
98
|
+
except tokens.InvalidTokenError as e:
|
|
99
|
+
raise AuthError(str(e)) from e
|
|
100
|
+
|
|
101
|
+
def _decode_refresh_token(self, refresh_token: str) -> dict[str, Any]:
|
|
102
|
+
try:
|
|
103
|
+
claims = tokens.decode_token(refresh_token, "refresh")
|
|
104
|
+
except tokens.InvalidTokenError as e:
|
|
105
|
+
raise AuthError(str(e)) from e
|
|
106
|
+
if self.revoked_tokens.is_revoked(claims["jti"]):
|
|
107
|
+
raise AuthError("Refresh token has been revoked")
|
|
108
|
+
return claims
|
|
109
|
+
|
|
110
|
+
def _revoke(self, claims: dict[str, Any]) -> None:
|
|
111
|
+
self.revoked_tokens.revoke(claims["jti"], datetime.fromtimestamp(claims["exp"], UTC))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _issue_tokens(user_id: str) -> TokenPair:
|
|
115
|
+
return TokenPair(
|
|
116
|
+
access_token=tokens.create_access_token(user_id),
|
|
117
|
+
refresh_token=tokens.create_refresh_token(user_id),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@cache
|
|
122
|
+
def _dummy_hash() -> str:
|
|
123
|
+
return hash_password("dummy password for constant-time login failures")
|