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.
@@ -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
+ )