veltro-suite-auth 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.
- veltro_suite_auth-0.1.0/.gitignore +103 -0
- veltro_suite_auth-0.1.0/PKG-INFO +127 -0
- veltro_suite_auth-0.1.0/README.md +107 -0
- veltro_suite_auth-0.1.0/pyproject.toml +32 -0
- veltro_suite_auth-0.1.0/src/veltro_suite_auth/__init__.py +60 -0
- veltro_suite_auth-0.1.0/src/veltro_suite_auth/service_token.py +234 -0
- veltro_suite_auth-0.1.0/src/veltro_suite_auth/session.py +155 -0
- veltro_suite_auth-0.1.0/src/veltro_suite_auth/testing.py +38 -0
- veltro_suite_auth-0.1.0/src/veltro_suite_auth/vectors/service-token-vectors.json +46 -0
- veltro_suite_auth-0.1.0/src/veltro_suite_auth/vectors/session-fixture.json +15 -0
- veltro_suite_auth-0.1.0/tests/conformance/test_fresh_session_roundtrip.py +56 -0
- veltro_suite_auth-0.1.0/tests/test_service_token.py +248 -0
- veltro_suite_auth-0.1.0/tests/test_session.py +135 -0
- veltro_suite_auth-0.1.0/tests/test_vectors.py +43 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
/node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# testing
|
|
14
|
+
/coverage
|
|
15
|
+
|
|
16
|
+
# next.js
|
|
17
|
+
/.next/
|
|
18
|
+
/out/
|
|
19
|
+
|
|
20
|
+
# production
|
|
21
|
+
/build
|
|
22
|
+
|
|
23
|
+
# misc
|
|
24
|
+
.DS_Store
|
|
25
|
+
*.pem
|
|
26
|
+
|
|
27
|
+
# debug
|
|
28
|
+
npm-debug.log*
|
|
29
|
+
yarn-debug.log*
|
|
30
|
+
yarn-error.log*
|
|
31
|
+
.pnpm-debug.log*
|
|
32
|
+
|
|
33
|
+
# env files (can opt-in for committing if needed)
|
|
34
|
+
.env
|
|
35
|
+
.env.local
|
|
36
|
+
.env.development.local
|
|
37
|
+
.env.test.local
|
|
38
|
+
.env.production.local
|
|
39
|
+
|
|
40
|
+
# typescript
|
|
41
|
+
*.tsbuildinfo
|
|
42
|
+
next-env.d.ts
|
|
43
|
+
|
|
44
|
+
/src/generated/prisma
|
|
45
|
+
|
|
46
|
+
/docs/plans/*
|
|
47
|
+
/docs/screenshots/*
|
|
48
|
+
/docs/internal/*
|
|
49
|
+
# agent build output
|
|
50
|
+
/agent/bin/
|
|
51
|
+
.vectorflow/
|
|
52
|
+
/scripts/internal/
|
|
53
|
+
artifacts/
|
|
54
|
+
|
|
55
|
+
# codeql
|
|
56
|
+
.codeql/
|
|
57
|
+
# worktrees
|
|
58
|
+
.worktrees/
|
|
59
|
+
|
|
60
|
+
Thumbs.db
|
|
61
|
+
*.swp
|
|
62
|
+
*.swo
|
|
63
|
+
*~
|
|
64
|
+
.idea/
|
|
65
|
+
.vscode/
|
|
66
|
+
*.code-workspace
|
|
67
|
+
.env.*
|
|
68
|
+
!.env.example
|
|
69
|
+
|
|
70
|
+
# docs/cloud/ is gitignored: the directory holds Cloud-only documentation
|
|
71
|
+
# (threat model, runbooks, operator notes) that lives in the closed
|
|
72
|
+
# `cloud/` workspace and is published to vectorflow.sh/trust as a
|
|
73
|
+
# customer-facing summary. The full documents are not part of the AGPL OSS
|
|
74
|
+
# repo (see plan §15a R5 — 2026-05-17 removal).
|
|
75
|
+
!docs/cloud/
|
|
76
|
+
docs/cloud/*
|
|
77
|
+
node_modules/
|
|
78
|
+
.next/
|
|
79
|
+
dist/
|
|
80
|
+
build/
|
|
81
|
+
__pycache__/
|
|
82
|
+
*.pyc
|
|
83
|
+
.venv/
|
|
84
|
+
venv/
|
|
85
|
+
.pytest_cache/
|
|
86
|
+
*.egg-info/
|
|
87
|
+
target/
|
|
88
|
+
vendor/
|
|
89
|
+
*.log
|
|
90
|
+
coverage/
|
|
91
|
+
.cache/
|
|
92
|
+
tmp/
|
|
93
|
+
|
|
94
|
+
# playwright
|
|
95
|
+
/test-results/
|
|
96
|
+
/playwright-report/
|
|
97
|
+
/blob-report/
|
|
98
|
+
/playwright/.cache/
|
|
99
|
+
e2e/.auth/
|
|
100
|
+
/cloud/
|
|
101
|
+
|
|
102
|
+
# understand-anything intermediate scan output (local-only artifacts)
|
|
103
|
+
.understand-anything/
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: veltro-suite-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Canonical Veltro suite auth contract: Auth.js session decode + suite-service token mint/verify.
|
|
5
|
+
Project-URL: Homepage, https://github.com/veltrosecurity/vectorflow/tree/main/packages/suite-auth-py
|
|
6
|
+
Project-URL: Repository, https://github.com/veltrosecurity/vectorflow
|
|
7
|
+
Author: Veltro Security
|
|
8
|
+
License: AGPL-3.0-or-later
|
|
9
|
+
Keywords: auth,authjs,jwe,suite-service-token,veltro
|
|
10
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: cryptography>=43.0.0
|
|
18
|
+
Requires-Dist: python-jose[cryptography]>=3.5.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# veltro-suite-auth
|
|
22
|
+
|
|
23
|
+
The **canonical Veltro suite auth contract** — one published Python package for
|
|
24
|
+
the two cross-service security primitives shared across the Veltro suite
|
|
25
|
+
(VectorFlow, CHAD, WARDEN). Before this package each app carried its own
|
|
26
|
+
byte-identical copy of the decoder/verifier; CHAD and WARDEN now depend on this
|
|
27
|
+
package and carry none.
|
|
28
|
+
|
|
29
|
+
## What it provides
|
|
30
|
+
|
|
31
|
+
- **`veltro_suite_auth.session`** — decode VectorFlow's Auth.js v5 session
|
|
32
|
+
cookie (JWE, `alg=dir` / `enc=A256CBC-HS512`, HKDF-SHA256 key salted by the
|
|
33
|
+
cookie name) for delegated suite login.
|
|
34
|
+
- `decode_session(cookies, secret) -> SessionClaims | None`
|
|
35
|
+
- `SessionClaims`, `SessionError` / `SessionInvalid` / `SessionExpired`
|
|
36
|
+
- `SESSION_COOKIE_NAMES`, `derive_encryption_key`
|
|
37
|
+
- **`veltro_suite_auth.service_token`** — mint + verify the short-lived (60s),
|
|
38
|
+
scoped, HS256 suite-service token used for machine-to-machine calls between
|
|
39
|
+
suite apps (issuer `veltro-suite`, audience `suite-service`).
|
|
40
|
+
- `mint_service_token(secret, *, scope, svc, actor_email=None, actor_suite_role=None) -> str`
|
|
41
|
+
- `verify_service_token(token, secret, required_scope) -> ServiceTokenClaims`
|
|
42
|
+
- `ServiceTokenClaims`, `ServiceTokenError` / `ServiceTokenInvalid` / `ServiceTokenExpired`
|
|
43
|
+
- `SUITE_SERVICE_ISSUER`, `SUITE_SERVICE_AUDIENCE`, `SUITE_SERVICE_MINTERS`,
|
|
44
|
+
`SUITE_SERVICE_TOKEN_TTL_SECONDS`
|
|
45
|
+
- **`veltro_suite_auth.testing`** — golden cross-language vectors, shipped in
|
|
46
|
+
the wheel: `session_fixture()`, `service_token_vectors()`.
|
|
47
|
+
|
|
48
|
+
All decode/verify behavior (including the `suite_role` fail-open-to-viewer
|
|
49
|
+
semantics, family-aware scope satisfaction, and the optional `svc`/actor
|
|
50
|
+
claims) is preserved exactly from the original CHAD/WARDEN implementations.
|
|
51
|
+
|
|
52
|
+
## Contract source & rename map
|
|
53
|
+
|
|
54
|
+
The code was moved **verbatim** from CHAD's `backend/app/core/vf_session.py`
|
|
55
|
+
and `backend/app/core/suite_service_token.py`, plus WARDEN's minting logic from
|
|
56
|
+
`backend/app/services/suite_token.py`. Only symbol names and CHAD/WARDEN-specific
|
|
57
|
+
docstring prose changed:
|
|
58
|
+
|
|
59
|
+
| old (CHAD / WARDEN) | new (`veltro_suite_auth`) |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `decode_vf_session` | `decode_session` |
|
|
62
|
+
| `VfSessionClaims` | `SessionClaims` |
|
|
63
|
+
| `VfSessionError` / `VfSessionInvalid` / `VfSessionExpired` | `SessionError` / `SessionInvalid` / `SessionExpired` |
|
|
64
|
+
| `SESSION_COOKIE_NAMES`, `derive_encryption_key` | *unchanged* |
|
|
65
|
+
| `verify_suite_service_token` | `verify_service_token` |
|
|
66
|
+
| `SuiteServiceClaims` | `ServiceTokenClaims` |
|
|
67
|
+
| `SuiteServiceTokenError` / `...Invalid` / `...Expired` | `ServiceTokenError` / `ServiceTokenInvalid` / `ServiceTokenExpired` |
|
|
68
|
+
| `SUITE_SERVICE_ISSUER` / `SUITE_SERVICE_AUDIENCE` / `SUITE_SERVICE_MINTERS` | *unchanged* |
|
|
69
|
+
| WARDEN `mint_suite_service_token(scope)` (settings-coupled) | `mint_service_token(secret, *, scope, svc, ...)` (explicit params) |
|
|
70
|
+
|
|
71
|
+
## Cross-language conformance
|
|
72
|
+
|
|
73
|
+
The suite has a TypeScript minter (VectorFlow's
|
|
74
|
+
`src/server/services/suite/service-token.ts`) and this Python decoder/verifier.
|
|
75
|
+
Two per-PR gates in the VectorFlow repo keep them from drifting:
|
|
76
|
+
|
|
77
|
+
- **`suite-auth-contract` CI job** — installs this package and runs its tests,
|
|
78
|
+
including `tests/conformance/test_fresh_session_roundtrip.py`, which shells
|
|
79
|
+
`node scripts/mint-test-session.mjs` to mint a **fresh** Auth.js cookie and
|
|
80
|
+
decodes it here. Catches VF-minter ⟂ Python-decoder drift on the session
|
|
81
|
+
cookie.
|
|
82
|
+
- **`contract-vectors.test.ts`** — pins the committed
|
|
83
|
+
`vectors/service-token-vectors.json` against the live TS minters
|
|
84
|
+
(`mintSuiteServiceToken` / `mintSuiteExternalToken`). Catches TS-minter
|
|
85
|
+
drift on the service token; the Python side of the same vectors is asserted
|
|
86
|
+
by `tests/test_vectors.py`.
|
|
87
|
+
|
|
88
|
+
## Regenerating the vectors
|
|
89
|
+
|
|
90
|
+
- **`session-fixture.json`** — a fixed Auth.js cookie. Regenerate with
|
|
91
|
+
VectorFlow's script (from the repo root):
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
node scripts/mint-test-session.mjs > packages/suite-auth-py/src/veltro_suite_auth/vectors/session-fixture.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- **`service-token-vectors.json`** — the four suite-service token vectors
|
|
98
|
+
(`settings:read`, `settings:write` with actor; `external:read`,
|
|
99
|
+
`external:write` without actor), captured from the real TS minters under a
|
|
100
|
+
frozen clock. Regenerate with (from the repo root):
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
EMIT_CONTRACT_VECTORS=1 pnpm vitest run src/server/services/suite/__tests__/contract-vectors.test.ts
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Local development
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
# from packages/suite-auth-py/
|
|
110
|
+
python3.11 -m venv .venv && . .venv/bin/activate
|
|
111
|
+
pip install -e . pytest
|
|
112
|
+
pytest -v # conformance test needs `node` + a repo checkout, else it skips
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Manual publish (after merge to main)
|
|
116
|
+
|
|
117
|
+
There is no publish workflow — publishing is a manual step by the maintainer
|
|
118
|
+
(matching the design system's manual-npm-publish precedent). From a clean
|
|
119
|
+
checkout of `main`:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
cd packages/suite-auth-py
|
|
123
|
+
uv build # or: python -m build
|
|
124
|
+
uv publish # or: twine upload dist/* (needs a PyPI token)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Never republish an existing version — ship a new `0.1.x` for any fix.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# veltro-suite-auth
|
|
2
|
+
|
|
3
|
+
The **canonical Veltro suite auth contract** — one published Python package for
|
|
4
|
+
the two cross-service security primitives shared across the Veltro suite
|
|
5
|
+
(VectorFlow, CHAD, WARDEN). Before this package each app carried its own
|
|
6
|
+
byte-identical copy of the decoder/verifier; CHAD and WARDEN now depend on this
|
|
7
|
+
package and carry none.
|
|
8
|
+
|
|
9
|
+
## What it provides
|
|
10
|
+
|
|
11
|
+
- **`veltro_suite_auth.session`** — decode VectorFlow's Auth.js v5 session
|
|
12
|
+
cookie (JWE, `alg=dir` / `enc=A256CBC-HS512`, HKDF-SHA256 key salted by the
|
|
13
|
+
cookie name) for delegated suite login.
|
|
14
|
+
- `decode_session(cookies, secret) -> SessionClaims | None`
|
|
15
|
+
- `SessionClaims`, `SessionError` / `SessionInvalid` / `SessionExpired`
|
|
16
|
+
- `SESSION_COOKIE_NAMES`, `derive_encryption_key`
|
|
17
|
+
- **`veltro_suite_auth.service_token`** — mint + verify the short-lived (60s),
|
|
18
|
+
scoped, HS256 suite-service token used for machine-to-machine calls between
|
|
19
|
+
suite apps (issuer `veltro-suite`, audience `suite-service`).
|
|
20
|
+
- `mint_service_token(secret, *, scope, svc, actor_email=None, actor_suite_role=None) -> str`
|
|
21
|
+
- `verify_service_token(token, secret, required_scope) -> ServiceTokenClaims`
|
|
22
|
+
- `ServiceTokenClaims`, `ServiceTokenError` / `ServiceTokenInvalid` / `ServiceTokenExpired`
|
|
23
|
+
- `SUITE_SERVICE_ISSUER`, `SUITE_SERVICE_AUDIENCE`, `SUITE_SERVICE_MINTERS`,
|
|
24
|
+
`SUITE_SERVICE_TOKEN_TTL_SECONDS`
|
|
25
|
+
- **`veltro_suite_auth.testing`** — golden cross-language vectors, shipped in
|
|
26
|
+
the wheel: `session_fixture()`, `service_token_vectors()`.
|
|
27
|
+
|
|
28
|
+
All decode/verify behavior (including the `suite_role` fail-open-to-viewer
|
|
29
|
+
semantics, family-aware scope satisfaction, and the optional `svc`/actor
|
|
30
|
+
claims) is preserved exactly from the original CHAD/WARDEN implementations.
|
|
31
|
+
|
|
32
|
+
## Contract source & rename map
|
|
33
|
+
|
|
34
|
+
The code was moved **verbatim** from CHAD's `backend/app/core/vf_session.py`
|
|
35
|
+
and `backend/app/core/suite_service_token.py`, plus WARDEN's minting logic from
|
|
36
|
+
`backend/app/services/suite_token.py`. Only symbol names and CHAD/WARDEN-specific
|
|
37
|
+
docstring prose changed:
|
|
38
|
+
|
|
39
|
+
| old (CHAD / WARDEN) | new (`veltro_suite_auth`) |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `decode_vf_session` | `decode_session` |
|
|
42
|
+
| `VfSessionClaims` | `SessionClaims` |
|
|
43
|
+
| `VfSessionError` / `VfSessionInvalid` / `VfSessionExpired` | `SessionError` / `SessionInvalid` / `SessionExpired` |
|
|
44
|
+
| `SESSION_COOKIE_NAMES`, `derive_encryption_key` | *unchanged* |
|
|
45
|
+
| `verify_suite_service_token` | `verify_service_token` |
|
|
46
|
+
| `SuiteServiceClaims` | `ServiceTokenClaims` |
|
|
47
|
+
| `SuiteServiceTokenError` / `...Invalid` / `...Expired` | `ServiceTokenError` / `ServiceTokenInvalid` / `ServiceTokenExpired` |
|
|
48
|
+
| `SUITE_SERVICE_ISSUER` / `SUITE_SERVICE_AUDIENCE` / `SUITE_SERVICE_MINTERS` | *unchanged* |
|
|
49
|
+
| WARDEN `mint_suite_service_token(scope)` (settings-coupled) | `mint_service_token(secret, *, scope, svc, ...)` (explicit params) |
|
|
50
|
+
|
|
51
|
+
## Cross-language conformance
|
|
52
|
+
|
|
53
|
+
The suite has a TypeScript minter (VectorFlow's
|
|
54
|
+
`src/server/services/suite/service-token.ts`) and this Python decoder/verifier.
|
|
55
|
+
Two per-PR gates in the VectorFlow repo keep them from drifting:
|
|
56
|
+
|
|
57
|
+
- **`suite-auth-contract` CI job** — installs this package and runs its tests,
|
|
58
|
+
including `tests/conformance/test_fresh_session_roundtrip.py`, which shells
|
|
59
|
+
`node scripts/mint-test-session.mjs` to mint a **fresh** Auth.js cookie and
|
|
60
|
+
decodes it here. Catches VF-minter ⟂ Python-decoder drift on the session
|
|
61
|
+
cookie.
|
|
62
|
+
- **`contract-vectors.test.ts`** — pins the committed
|
|
63
|
+
`vectors/service-token-vectors.json` against the live TS minters
|
|
64
|
+
(`mintSuiteServiceToken` / `mintSuiteExternalToken`). Catches TS-minter
|
|
65
|
+
drift on the service token; the Python side of the same vectors is asserted
|
|
66
|
+
by `tests/test_vectors.py`.
|
|
67
|
+
|
|
68
|
+
## Regenerating the vectors
|
|
69
|
+
|
|
70
|
+
- **`session-fixture.json`** — a fixed Auth.js cookie. Regenerate with
|
|
71
|
+
VectorFlow's script (from the repo root):
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
node scripts/mint-test-session.mjs > packages/suite-auth-py/src/veltro_suite_auth/vectors/session-fixture.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **`service-token-vectors.json`** — the four suite-service token vectors
|
|
78
|
+
(`settings:read`, `settings:write` with actor; `external:read`,
|
|
79
|
+
`external:write` without actor), captured from the real TS minters under a
|
|
80
|
+
frozen clock. Regenerate with (from the repo root):
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
EMIT_CONTRACT_VECTORS=1 pnpm vitest run src/server/services/suite/__tests__/contract-vectors.test.ts
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Local development
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
# from packages/suite-auth-py/
|
|
90
|
+
python3.11 -m venv .venv && . .venv/bin/activate
|
|
91
|
+
pip install -e . pytest
|
|
92
|
+
pytest -v # conformance test needs `node` + a repo checkout, else it skips
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Manual publish (after merge to main)
|
|
96
|
+
|
|
97
|
+
There is no publish workflow — publishing is a manual step by the maintainer
|
|
98
|
+
(matching the design system's manual-npm-publish precedent). From a clean
|
|
99
|
+
checkout of `main`:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
cd packages/suite-auth-py
|
|
103
|
+
uv build # or: python -m build
|
|
104
|
+
uv publish # or: twine upload dist/* (needs a PyPI token)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Never republish an existing version — ship a new `0.1.x` for any fix.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "veltro-suite-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Canonical Veltro suite auth contract: Auth.js session decode + suite-service token mint/verify."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "AGPL-3.0-or-later" }
|
|
12
|
+
authors = [{ name = "Veltro Security" }]
|
|
13
|
+
keywords = ["veltro", "auth", "authjs", "jwe", "suite-service-token"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"python-jose[cryptography]>=3.5.0",
|
|
24
|
+
"cryptography>=43.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/veltrosecurity/vectorflow/tree/main/packages/suite-auth-py"
|
|
29
|
+
Repository = "https://github.com/veltrosecurity/vectorflow"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/veltro_suite_auth"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""veltro-suite-auth — the canonical Veltro suite auth contract.
|
|
2
|
+
|
|
3
|
+
One source of truth for the two cross-service security primitives shared by the
|
|
4
|
+
Veltro suite apps (VectorFlow, CHAD, WARDEN):
|
|
5
|
+
|
|
6
|
+
- ``session`` — decode VectorFlow's Auth.js v5 session JWE for delegated login.
|
|
7
|
+
- ``service_token`` — mint + verify the short-lived, scoped suite-service JWS
|
|
8
|
+
used for machine-to-machine calls between suite apps.
|
|
9
|
+
|
|
10
|
+
CHAD and WARDEN import from here instead of carrying their own copies; the
|
|
11
|
+
golden vectors under ``vectors/`` (surfaced by ``veltro_suite_auth.testing``)
|
|
12
|
+
pin cross-language conformance with VectorFlow's TypeScript minters.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .service_token import (
|
|
16
|
+
SUITE_SERVICE_AUDIENCE,
|
|
17
|
+
SUITE_SERVICE_ISSUER,
|
|
18
|
+
SUITE_SERVICE_MINTERS,
|
|
19
|
+
SUITE_SERVICE_TOKEN_TTL_SECONDS,
|
|
20
|
+
ServiceTokenClaims,
|
|
21
|
+
ServiceTokenError,
|
|
22
|
+
ServiceTokenExpired,
|
|
23
|
+
ServiceTokenInvalid,
|
|
24
|
+
mint_service_token,
|
|
25
|
+
verify_service_token,
|
|
26
|
+
)
|
|
27
|
+
from .session import (
|
|
28
|
+
SESSION_COOKIE_NAMES,
|
|
29
|
+
SessionClaims,
|
|
30
|
+
SessionError,
|
|
31
|
+
SessionExpired,
|
|
32
|
+
SessionInvalid,
|
|
33
|
+
decode_session,
|
|
34
|
+
derive_encryption_key,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"__version__",
|
|
41
|
+
# session decode
|
|
42
|
+
"SESSION_COOKIE_NAMES",
|
|
43
|
+
"SessionClaims",
|
|
44
|
+
"SessionError",
|
|
45
|
+
"SessionExpired",
|
|
46
|
+
"SessionInvalid",
|
|
47
|
+
"decode_session",
|
|
48
|
+
"derive_encryption_key",
|
|
49
|
+
# suite-service token mint/verify
|
|
50
|
+
"SUITE_SERVICE_AUDIENCE",
|
|
51
|
+
"SUITE_SERVICE_ISSUER",
|
|
52
|
+
"SUITE_SERVICE_MINTERS",
|
|
53
|
+
"SUITE_SERVICE_TOKEN_TTL_SECONDS",
|
|
54
|
+
"ServiceTokenClaims",
|
|
55
|
+
"ServiceTokenError",
|
|
56
|
+
"ServiceTokenExpired",
|
|
57
|
+
"ServiceTokenInvalid",
|
|
58
|
+
"mint_service_token",
|
|
59
|
+
"verify_service_token",
|
|
60
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Mint and verify suite-service tokens for scoped service-to-service calls.
|
|
2
|
+
|
|
3
|
+
Cross-service token plane (Veltro suite): a suite app (VectorFlow, WARDEN, or
|
|
4
|
+
CHAD) sometimes needs to make an AUTHORIZED machine call into a small set of
|
|
5
|
+
another suite app's endpoints. This is a *separate, narrower* mechanism from
|
|
6
|
+
``session.py``:
|
|
7
|
+
|
|
8
|
+
- ``session.py`` decodes the suite's full Auth.js *user session* cookie (a
|
|
9
|
+
JWE) — it authenticates an interactive human sitting in a browser with that
|
|
10
|
+
cookie, for delegated login.
|
|
11
|
+
- This module verifies (and mints) a short-lived, purpose-built JWT that a
|
|
12
|
+
suite app's *server* mints per outbound call (never a cookie, never handed to
|
|
13
|
+
a browser). It carries no session state — just an audience, the minting
|
|
14
|
+
service (``svc``), an explicit scope, and (optionally) the acting admin's
|
|
15
|
+
identity for audit attribution. A leaked/replayed token is useless outside
|
|
16
|
+
its ~60s TTL and cannot reach anything beyond the scope it names: there is no
|
|
17
|
+
general trust bridge between the apps.
|
|
18
|
+
|
|
19
|
+
All suite tokens are signed with the shared suite secret (VF's NEXTAUTH_SECRET,
|
|
20
|
+
fanned out to every suite app as VELTRO_SESSION_SECRET — see docker-compose.yml)
|
|
21
|
+
because the delegated-login slice already established that as the suite's one
|
|
22
|
+
shared secret. The ``suite-service`` audience keeps these machine tokens from
|
|
23
|
+
being confused with the Auth.js cookie even though they share a signing key.
|
|
24
|
+
|
|
25
|
+
See ``docs/adr/004`` (in the veltro umbrella) for the full spec: issuer
|
|
26
|
+
``veltro-suite``, the ``svc`` minter claim, and the settings:* / external:*
|
|
27
|
+
scope table.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import binascii
|
|
32
|
+
import hashlib
|
|
33
|
+
import hmac
|
|
34
|
+
import json
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
|
|
39
|
+
SUITE_SERVICE_AUDIENCE = "suite-service"
|
|
40
|
+
SUITE_SERVICE_ISSUER = "veltro-suite"
|
|
41
|
+
|
|
42
|
+
# Suite apps that may mint a suite-service token (the ``svc`` claim). Purely
|
|
43
|
+
# informational for audit attribution — verification does not authorize by
|
|
44
|
+
# minter, only by scope.
|
|
45
|
+
SUITE_SERVICE_MINTERS = ("vectorflow", "warden", "chad")
|
|
46
|
+
|
|
47
|
+
# Minted-token lifetime. Deliberately short: a suite-service token is minted
|
|
48
|
+
# immediately before use and consumed within the same outbound request — there
|
|
49
|
+
# is no legitimate reason for one to outlive a single HTTP round trip by much.
|
|
50
|
+
# Generous enough to absorb network latency/retries, tight enough that a
|
|
51
|
+
# captured token is useless shortly after.
|
|
52
|
+
SUITE_SERVICE_TOKEN_TTL_SECONDS = 60
|
|
53
|
+
|
|
54
|
+
# Coupled to VectorFlow's suite_role contract (suite-role.ts), same tuple
|
|
55
|
+
# session.py uses for the interactive-session claim.
|
|
56
|
+
_SUITE_ROLES = ("admin", "editor", "viewer")
|
|
57
|
+
|
|
58
|
+
# Scope model: two independent families, each ordered weakest -> strongest.
|
|
59
|
+
# "<family>:write" implies "<family>:read" (a caller authorized to write is
|
|
60
|
+
# trivially authorized to read), but the families are NOT interchangeable — an
|
|
61
|
+
# "external:*" token can never satisfy a "settings:*" requirement and vice
|
|
62
|
+
# versa. Each entry maps a scope to its (family, rank).
|
|
63
|
+
_SCOPE_RANK: dict[str, tuple[str, int]] = {
|
|
64
|
+
"settings:read": ("settings", 0),
|
|
65
|
+
"settings:write": ("settings", 1),
|
|
66
|
+
"external:read": ("external", 0),
|
|
67
|
+
"external:write": ("external", 1),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ServiceTokenClaims:
|
|
73
|
+
scope: str
|
|
74
|
+
svc: str | None
|
|
75
|
+
actor_email: str | None
|
|
76
|
+
actor_suite_role: str | None
|
|
77
|
+
exp: int
|
|
78
|
+
jti: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ServiceTokenError(Exception):
|
|
82
|
+
"""Base error for suite-service token verification."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ServiceTokenInvalid(ServiceTokenError):
|
|
86
|
+
"""Token present but undecryptable, malformed, or not authorized."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ServiceTokenExpired(ServiceTokenError):
|
|
90
|
+
"""Token verified fine but its exp is in the past."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _b64url(raw: bytes) -> str:
|
|
94
|
+
"""base64url without padding — matches the minters and the verifier's decode."""
|
|
95
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _b64url_decode(segment: str) -> bytes:
|
|
99
|
+
padding = "=" * (-len(segment) % 4)
|
|
100
|
+
try:
|
|
101
|
+
return base64.urlsafe_b64decode(segment + padding)
|
|
102
|
+
except (binascii.Error, ValueError) as exc:
|
|
103
|
+
raise ServiceTokenInvalid(f"malformed base64url segment: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def mint_service_token(
|
|
107
|
+
secret: str,
|
|
108
|
+
*,
|
|
109
|
+
scope: str,
|
|
110
|
+
svc: str,
|
|
111
|
+
actor_email: str | None = None,
|
|
112
|
+
actor_suite_role: str | None = None,
|
|
113
|
+
) -> str:
|
|
114
|
+
"""Mint a 60s HS256 suite-service token authorizing one scoped call.
|
|
115
|
+
|
|
116
|
+
``secret`` is the shared suite secret (VF's NEXTAUTH_SECRET, fanned out to
|
|
117
|
+
every suite app as VELTRO_SESSION_SECRET). ``scope`` is the scope the
|
|
118
|
+
outbound call needs (e.g. ``external:read`` for a GET, ``external:write``
|
|
119
|
+
for a writeback POST). ``svc`` is the minting service identity carried in
|
|
120
|
+
the ``svc`` claim (for audit). ``actor_email`` / ``actor_suite_role`` are
|
|
121
|
+
OPTIONAL interactive-admin attribution: when both are None (a
|
|
122
|
+
machine-to-machine call with no human in the loop) neither claim is
|
|
123
|
+
emitted. Compact HS256 serialization, unpadded base64url — the exact format
|
|
124
|
+
``verify_service_token`` accepts. Authorization of the actor against the
|
|
125
|
+
requested scope is the caller's job; this function only signs.
|
|
126
|
+
"""
|
|
127
|
+
now = int(time.time())
|
|
128
|
+
header = {"alg": "HS256", "typ": "JWT"}
|
|
129
|
+
payload: dict[str, object] = {
|
|
130
|
+
"iss": SUITE_SERVICE_ISSUER,
|
|
131
|
+
"aud": SUITE_SERVICE_AUDIENCE,
|
|
132
|
+
"svc": svc,
|
|
133
|
+
"scope": scope,
|
|
134
|
+
"iat": now,
|
|
135
|
+
"exp": now + SUITE_SERVICE_TOKEN_TTL_SECONDS,
|
|
136
|
+
"jti": str(uuid.uuid4()),
|
|
137
|
+
}
|
|
138
|
+
if actor_email is not None:
|
|
139
|
+
payload["actor_email"] = actor_email
|
|
140
|
+
if actor_suite_role is not None:
|
|
141
|
+
payload["actor_suite_role"] = actor_suite_role
|
|
142
|
+
encoded_header = _b64url(json.dumps(header, separators=(",", ":")).encode())
|
|
143
|
+
encoded_payload = _b64url(json.dumps(payload, separators=(",", ":")).encode())
|
|
144
|
+
signing_input = f"{encoded_header}.{encoded_payload}".encode()
|
|
145
|
+
signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
|
|
146
|
+
return f"{encoded_header}.{encoded_payload}.{_b64url(signature)}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def verify_service_token(
|
|
150
|
+
token: str, secret: str, required_scope: str
|
|
151
|
+
) -> ServiceTokenClaims:
|
|
152
|
+
"""Verify a suite-minted service token (HS256, compact serialization).
|
|
153
|
+
|
|
154
|
+
Raises ``ServiceTokenInvalid`` for any structural/signature/claim failure
|
|
155
|
+
and ``ServiceTokenExpired`` when the signature checks out but ``exp`` has
|
|
156
|
+
passed. Callers should treat both as "reject the request" — the split
|
|
157
|
+
exists only so logging/metrics can distinguish expiry from tampering.
|
|
158
|
+
"""
|
|
159
|
+
parts = token.split(".")
|
|
160
|
+
if len(parts) != 3:
|
|
161
|
+
raise ServiceTokenInvalid("malformed token: expected 3 segments")
|
|
162
|
+
encoded_header, encoded_payload, encoded_signature = parts
|
|
163
|
+
|
|
164
|
+
signing_input = f"{encoded_header}.{encoded_payload}".encode()
|
|
165
|
+
expected_sig = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
|
|
166
|
+
actual_sig = _b64url_decode(encoded_signature)
|
|
167
|
+
if not hmac.compare_digest(expected_sig, actual_sig):
|
|
168
|
+
raise ServiceTokenInvalid("signature mismatch")
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
header = json.loads(_b64url_decode(encoded_header))
|
|
172
|
+
payload = json.loads(_b64url_decode(encoded_payload))
|
|
173
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
174
|
+
raise ServiceTokenInvalid(f"malformed payload: {exc}") from exc
|
|
175
|
+
|
|
176
|
+
if not isinstance(header, dict) or header.get("alg") != "HS256":
|
|
177
|
+
raise ServiceTokenInvalid("unsupported or missing alg")
|
|
178
|
+
if not isinstance(payload, dict):
|
|
179
|
+
raise ServiceTokenInvalid("payload is not a JSON object")
|
|
180
|
+
|
|
181
|
+
if payload.get("iss") != SUITE_SERVICE_ISSUER:
|
|
182
|
+
raise ServiceTokenInvalid("bad issuer")
|
|
183
|
+
if payload.get("aud") != SUITE_SERVICE_AUDIENCE:
|
|
184
|
+
raise ServiceTokenInvalid("bad audience")
|
|
185
|
+
|
|
186
|
+
exp = payload.get("exp")
|
|
187
|
+
if not isinstance(exp, int):
|
|
188
|
+
raise ServiceTokenInvalid("missing integer 'exp' claim")
|
|
189
|
+
if exp <= int(time.time()):
|
|
190
|
+
raise ServiceTokenExpired(f"suite-service token expired at {exp}")
|
|
191
|
+
|
|
192
|
+
scope = payload.get("scope")
|
|
193
|
+
if scope not in _SCOPE_RANK:
|
|
194
|
+
raise ServiceTokenInvalid(f"unknown scope {scope!r}")
|
|
195
|
+
if required_scope not in _SCOPE_RANK:
|
|
196
|
+
raise ServiceTokenInvalid(f"unknown required_scope {required_scope!r}")
|
|
197
|
+
token_family, token_rank = _SCOPE_RANK[scope]
|
|
198
|
+
req_family, req_rank = _SCOPE_RANK[required_scope]
|
|
199
|
+
if token_family != req_family or token_rank < req_rank:
|
|
200
|
+
raise ServiceTokenInvalid(
|
|
201
|
+
f"insufficient scope: token has {scope!r}, endpoint requires {required_scope!r}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Minting service (``svc``) — exposed for audit. Optional so a token minted
|
|
205
|
+
# before the claim existed still verifies; when present it must be a
|
|
206
|
+
# non-empty string.
|
|
207
|
+
svc = payload.get("svc")
|
|
208
|
+
if svc is not None and (not isinstance(svc, str) or not svc):
|
|
209
|
+
raise ServiceTokenInvalid("svc claim present but not a non-empty string")
|
|
210
|
+
|
|
211
|
+
# Actor attribution is OPTIONAL: interactive suite-admin calls carry it,
|
|
212
|
+
# automated service-to-service calls (no human in the loop) omit it.
|
|
213
|
+
actor_email = payload.get("actor_email")
|
|
214
|
+
if actor_email is not None and (not isinstance(actor_email, str) or not actor_email):
|
|
215
|
+
raise ServiceTokenInvalid("actor_email present but not a non-empty string")
|
|
216
|
+
|
|
217
|
+
actor_suite_role = payload.get("actor_suite_role")
|
|
218
|
+
if actor_suite_role is not None and actor_suite_role not in _SUITE_ROLES:
|
|
219
|
+
raise ServiceTokenInvalid(
|
|
220
|
+
f"actor_suite_role {actor_suite_role!r} not in {_SUITE_ROLES}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
jti = payload.get("jti")
|
|
224
|
+
if not isinstance(jti, str) or not jti:
|
|
225
|
+
raise ServiceTokenInvalid("missing jti claim")
|
|
226
|
+
|
|
227
|
+
return ServiceTokenClaims(
|
|
228
|
+
scope=scope,
|
|
229
|
+
svc=svc,
|
|
230
|
+
actor_email=actor_email,
|
|
231
|
+
actor_suite_role=actor_suite_role,
|
|
232
|
+
exp=exp,
|
|
233
|
+
jti=jti,
|
|
234
|
+
)
|