cpkit 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.
- cpkit/README.md +82 -0
- cpkit/__init__.py +42 -0
- cpkit/admin.py +53 -0
- cpkit/app.py +130 -0
- cpkit/audit/README.md +33 -0
- cpkit/audit/__init__.py +44 -0
- cpkit/audit/events_service.py +36 -0
- cpkit/audit/recorder.py +240 -0
- cpkit/audit/repository.py +64 -0
- cpkit/audit/router.py +46 -0
- cpkit/audit/service.py +34 -0
- cpkit/audit/types.py +38 -0
- cpkit/auth/README.md +38 -0
- cpkit/auth/__init__.py +123 -0
- cpkit/auth/api_key_router.py +52 -0
- cpkit/auth/api_key_service.py +143 -0
- cpkit/auth/api_keys.py +143 -0
- cpkit/auth/bundle.py +113 -0
- cpkit/auth/claims.py +37 -0
- cpkit/auth/config.py +162 -0
- cpkit/auth/dependencies.py +155 -0
- cpkit/auth/oidc.py +703 -0
- cpkit/auth/redirects.py +12 -0
- cpkit/auth/repositories.py +181 -0
- cpkit/auth/router.py +214 -0
- cpkit/auth/secrets.py +78 -0
- cpkit/auth/types.py +49 -0
- cpkit/bundle.py +252 -0
- cpkit/cli/README.md +21 -0
- cpkit/cli/__init__.py +14 -0
- cpkit/cli/__main__.py +5 -0
- cpkit/cli/base.py +200 -0
- cpkit/cli/schema.py +30 -0
- cpkit/cli/server.py +22 -0
- cpkit/config/README.md +13 -0
- cpkit/config/__init__.py +9 -0
- cpkit/config/env.py +31 -0
- cpkit/db/README.md +24 -0
- cpkit/db/__init__.py +23 -0
- cpkit/db/postgres.py +252 -0
- cpkit/dependencies.py +73 -0
- cpkit/errors/README.md +22 -0
- cpkit/errors/__init__.py +35 -0
- cpkit/errors/http.py +45 -0
- cpkit/errors/repository.py +32 -0
- cpkit/errors/service.py +74 -0
- cpkit/jobs/README.md +29 -0
- cpkit/jobs/__init__.py +49 -0
- cpkit/jobs/maintenance.py +15 -0
- cpkit/jobs/repository.py +306 -0
- cpkit/jobs/router.py +105 -0
- cpkit/jobs/service.py +138 -0
- cpkit/jobs/types.py +67 -0
- cpkit/jobs/worker.py +200 -0
- cpkit/logging/README.md +19 -0
- cpkit/logging/__init__.py +13 -0
- cpkit/logging/context.py +29 -0
- cpkit/logging/middleware.py +55 -0
- cpkit/logging/setup.py +81 -0
- cpkit/playbooks/README.md +25 -0
- cpkit/playbooks/__init__.py +40 -0
- cpkit/playbooks/ansible.py +359 -0
- cpkit/playbooks/repository.py +95 -0
- cpkit/playbooks/router.py +95 -0
- cpkit/playbooks/service.py +232 -0
- cpkit/playbooks/types.py +43 -0
- cpkit/repository.py +63 -0
- cpkit/resources/__init__.py +17 -0
- cpkit/resources/ddl.sql +150 -0
- cpkit/settings/README.md +22 -0
- cpkit/settings/__init__.py +18 -0
- cpkit/settings/keys.py +30 -0
- cpkit/settings/repository.py +108 -0
- cpkit/settings/router.py +62 -0
- cpkit/settings/service.py +105 -0
- cpkit/settings/types.py +25 -0
- cpkit/time.py +4 -0
- cpkit/webapp/README.md +71 -0
- cpkit/webapp/__init__.py +12 -0
- cpkit/webapp/index.html +970 -0
- cpkit/webapp/script.js +1690 -0
- cpkit/webapp/style.css +1561 -0
- cpkit-0.1.0.dist-info/METADATA +105 -0
- cpkit-0.1.0.dist-info/RECORD +90 -0
- cpkit-0.1.0.dist-info/WHEEL +4 -0
- cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- resources/README.md +16 -0
- resources/repository_maintenance_guide.md +90 -0
- resources/webapp_extension_guide.md +706 -0
- resources/webapp_extension_template.js +130 -0
cpkit/auth/redirects.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Redirect target validation helpers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def safe_next_path(next_path: str | None) -> str:
|
|
5
|
+
"""Normalize redirect targets so only in-app absolute paths are allowed."""
|
|
6
|
+
if not next_path:
|
|
7
|
+
return "/"
|
|
8
|
+
if not next_path.startswith("/"):
|
|
9
|
+
return "/"
|
|
10
|
+
if next_path.startswith("//"):
|
|
11
|
+
return "/"
|
|
12
|
+
return next_path
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Repository mixins for framework-owned auth tables."""
|
|
2
|
+
|
|
3
|
+
from cpkit.db import execute_stmt, fetch_all, fetch_one
|
|
4
|
+
|
|
5
|
+
from .types import ApiKeyRecord, ApiKeySummary, OIDCSessionRecord, RoleGroupMap
|
|
6
|
+
|
|
7
|
+
API_KEYS_TABLE = "cpkit.api_keys"
|
|
8
|
+
OIDC_SESSIONS_TABLE = "cpkit.oidc_sessions"
|
|
9
|
+
ROLE_GROUP_MAPPINGS_TABLE = "cpkit.role_to_groups_mappings"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIKeysRepositoryMixin:
|
|
13
|
+
api_key_record_type = ApiKeyRecord
|
|
14
|
+
api_key_summary_type = ApiKeySummary
|
|
15
|
+
|
|
16
|
+
def get_api_key(self, access_key: str):
|
|
17
|
+
return fetch_one(
|
|
18
|
+
f"""
|
|
19
|
+
SELECT access_key, encrypted_secret_access_key, owner, valid_until, roles
|
|
20
|
+
FROM {API_KEYS_TABLE}
|
|
21
|
+
WHERE access_key = %s
|
|
22
|
+
""",
|
|
23
|
+
(access_key,),
|
|
24
|
+
self.api_key_record_type,
|
|
25
|
+
operation="api_keys.get",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def list_api_keys(self, access_key: str | None = None):
|
|
29
|
+
params: list[str] = []
|
|
30
|
+
sql = f"""
|
|
31
|
+
SELECT access_key, owner, valid_until, roles
|
|
32
|
+
FROM {API_KEYS_TABLE}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
if access_key is not None:
|
|
36
|
+
sql += " WHERE access_key = %s"
|
|
37
|
+
params.append(access_key)
|
|
38
|
+
|
|
39
|
+
sql += " ORDER BY access_key"
|
|
40
|
+
|
|
41
|
+
return fetch_all(
|
|
42
|
+
sql,
|
|
43
|
+
tuple(params),
|
|
44
|
+
self.api_key_summary_type,
|
|
45
|
+
operation="api_keys.list",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def create_api_key(
|
|
49
|
+
self,
|
|
50
|
+
api_key,
|
|
51
|
+
*,
|
|
52
|
+
owner: str,
|
|
53
|
+
encrypted_secret_access_key: bytes,
|
|
54
|
+
):
|
|
55
|
+
return fetch_one(
|
|
56
|
+
f"""
|
|
57
|
+
INSERT INTO {API_KEYS_TABLE} (
|
|
58
|
+
access_key,
|
|
59
|
+
encrypted_secret_access_key,
|
|
60
|
+
owner,
|
|
61
|
+
valid_until,
|
|
62
|
+
roles
|
|
63
|
+
)
|
|
64
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
65
|
+
RETURNING access_key, owner, valid_until, roles
|
|
66
|
+
""",
|
|
67
|
+
(
|
|
68
|
+
api_key.access_key,
|
|
69
|
+
encrypted_secret_access_key,
|
|
70
|
+
owner,
|
|
71
|
+
api_key.valid_until,
|
|
72
|
+
api_key.roles,
|
|
73
|
+
),
|
|
74
|
+
self.api_key_summary_type,
|
|
75
|
+
operation="api_keys.create",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def delete_api_key(self, access_key: str) -> None:
|
|
79
|
+
execute_stmt(
|
|
80
|
+
f"""
|
|
81
|
+
DELETE
|
|
82
|
+
FROM {API_KEYS_TABLE}
|
|
83
|
+
WHERE access_key = %s
|
|
84
|
+
""",
|
|
85
|
+
(access_key,),
|
|
86
|
+
operation="api_keys.delete",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OIDCSessionsRepositoryMixin:
|
|
91
|
+
oidc_session_record_type = OIDCSessionRecord
|
|
92
|
+
|
|
93
|
+
def get_oidc_session(self, session_id: str):
|
|
94
|
+
return fetch_one(
|
|
95
|
+
f"""
|
|
96
|
+
SELECT
|
|
97
|
+
session_id,
|
|
98
|
+
encrypted_id_token,
|
|
99
|
+
encrypted_refresh_token,
|
|
100
|
+
token_expires_at,
|
|
101
|
+
session_expires_at,
|
|
102
|
+
created_at,
|
|
103
|
+
updated_at
|
|
104
|
+
FROM {OIDC_SESSIONS_TABLE}
|
|
105
|
+
WHERE session_id = %s
|
|
106
|
+
AND session_expires_at > now()
|
|
107
|
+
""",
|
|
108
|
+
(session_id,),
|
|
109
|
+
self.oidc_session_record_type,
|
|
110
|
+
operation="auth.get_oidc_session",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def create_oidc_session(self, session) -> None:
|
|
114
|
+
execute_stmt(
|
|
115
|
+
f"""
|
|
116
|
+
INSERT INTO {OIDC_SESSIONS_TABLE}
|
|
117
|
+
(session_id, encrypted_id_token, encrypted_refresh_token,
|
|
118
|
+
token_expires_at, session_expires_at)
|
|
119
|
+
VALUES
|
|
120
|
+
(%s, %s, %s, %s, %s)
|
|
121
|
+
""",
|
|
122
|
+
(
|
|
123
|
+
session.session_id,
|
|
124
|
+
session.encrypted_id_token,
|
|
125
|
+
session.encrypted_refresh_token,
|
|
126
|
+
session.token_expires_at,
|
|
127
|
+
session.session_expires_at,
|
|
128
|
+
),
|
|
129
|
+
operation="auth.create_oidc_session",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def update_oidc_session(
|
|
133
|
+
self,
|
|
134
|
+
session_id: str,
|
|
135
|
+
*,
|
|
136
|
+
encrypted_id_token: bytes,
|
|
137
|
+
encrypted_refresh_token: bytes | None,
|
|
138
|
+
token_expires_at,
|
|
139
|
+
) -> None:
|
|
140
|
+
execute_stmt(
|
|
141
|
+
f"""
|
|
142
|
+
UPDATE {OIDC_SESSIONS_TABLE}
|
|
143
|
+
SET
|
|
144
|
+
encrypted_id_token = %s,
|
|
145
|
+
encrypted_refresh_token = %s,
|
|
146
|
+
token_expires_at = %s
|
|
147
|
+
WHERE session_id = %s
|
|
148
|
+
""",
|
|
149
|
+
(
|
|
150
|
+
encrypted_id_token,
|
|
151
|
+
encrypted_refresh_token,
|
|
152
|
+
token_expires_at,
|
|
153
|
+
session_id,
|
|
154
|
+
),
|
|
155
|
+
operation="auth.update_oidc_session",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def delete_oidc_session(self, session_id: str) -> None:
|
|
159
|
+
execute_stmt(
|
|
160
|
+
f"""
|
|
161
|
+
DELETE FROM {OIDC_SESSIONS_TABLE}
|
|
162
|
+
WHERE session_id = %s
|
|
163
|
+
""",
|
|
164
|
+
(session_id,),
|
|
165
|
+
operation="auth.delete_oidc_session",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class RoleGroupMappingsRepositoryMixin:
|
|
170
|
+
role_group_map_type = RoleGroupMap
|
|
171
|
+
|
|
172
|
+
def list_role_group_mappings(self) -> list[RoleGroupMap]:
|
|
173
|
+
return fetch_all(
|
|
174
|
+
f"""
|
|
175
|
+
SELECT role, groups
|
|
176
|
+
FROM {ROLE_GROUP_MAPPINGS_TABLE}
|
|
177
|
+
""",
|
|
178
|
+
(),
|
|
179
|
+
self.role_group_map_type,
|
|
180
|
+
operation="auth.list_role_group_mappings",
|
|
181
|
+
)
|
cpkit/auth/router.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""OIDC HTTP routes for cpkit FastAPI apps."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Security
|
|
8
|
+
from fastapi.responses import RedirectResponse
|
|
9
|
+
|
|
10
|
+
from .oidc import OIDCManager
|
|
11
|
+
from .redirects import safe_next_path
|
|
12
|
+
|
|
13
|
+
OIDC_STATE_COOKIE_NAME = "cp_oidc_state"
|
|
14
|
+
OIDC_NONCE_COOKIE_NAME = "cp_oidc_nonce"
|
|
15
|
+
OIDC_NEXT_COOKIE_NAME = "cp_oidc_next"
|
|
16
|
+
|
|
17
|
+
AuthEventHook = Callable[[Any, str, str, dict[str, Any] | None], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_oidc_router(
|
|
21
|
+
oidc: OIDCManager,
|
|
22
|
+
*,
|
|
23
|
+
get_repo: Callable[..., Any],
|
|
24
|
+
require_authenticated: Callable[..., Any],
|
|
25
|
+
get_audit_actor: Callable[..., str],
|
|
26
|
+
audit_event_hook: AuthEventHook | None = None,
|
|
27
|
+
prefix: str = "/auth",
|
|
28
|
+
tags: list[str] | None = None,
|
|
29
|
+
state_cookie_name: str = OIDC_STATE_COOKIE_NAME,
|
|
30
|
+
nonce_cookie_name: str = OIDC_NONCE_COOKIE_NAME,
|
|
31
|
+
next_cookie_name: str = OIDC_NEXT_COOKIE_NAME,
|
|
32
|
+
) -> APIRouter:
|
|
33
|
+
"""Create the standard OIDC auth router for a cpkit app."""
|
|
34
|
+
router = APIRouter(prefix=prefix, tags=tags or ["cpkit"])
|
|
35
|
+
|
|
36
|
+
def oidc_cookie_kwargs() -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"httponly": True,
|
|
39
|
+
"secure": oidc.config.cookie_secure,
|
|
40
|
+
"samesite": oidc.config.cookie_samesite,
|
|
41
|
+
"domain": oidc.config.cookie_domain,
|
|
42
|
+
"path": "/",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def log_auth_event(
|
|
46
|
+
repo: Any,
|
|
47
|
+
actor_id: str,
|
|
48
|
+
action: str,
|
|
49
|
+
details: dict[str, Any] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
if audit_event_hook is not None:
|
|
52
|
+
audit_event_hook(repo, actor_id, action, details)
|
|
53
|
+
|
|
54
|
+
@router.get("/login")
|
|
55
|
+
def oidc_login(
|
|
56
|
+
request: Request,
|
|
57
|
+
next: str = "/", # noqa: A002
|
|
58
|
+
repo: Any = Depends(get_repo),
|
|
59
|
+
):
|
|
60
|
+
"""Start the browser OIDC login flow and store anti-CSRF cookies."""
|
|
61
|
+
oidc.load_config(repo)
|
|
62
|
+
if not oidc.enabled:
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=404,
|
|
65
|
+
detail="OIDC is disabled.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
state = secrets.token_urlsafe(24)
|
|
69
|
+
nonce = secrets.token_urlsafe(24)
|
|
70
|
+
next_path = safe_next_path(next)
|
|
71
|
+
redirect_uri = oidc.config.redirect_uri or str(request.url_for("oidc_callback"))
|
|
72
|
+
auth_url = oidc.build_authorization_url(redirect_uri, state, nonce)
|
|
73
|
+
|
|
74
|
+
resp = RedirectResponse(auth_url, status_code=302)
|
|
75
|
+
cookie_kwargs = oidc_cookie_kwargs()
|
|
76
|
+
resp.set_cookie(state_cookie_name, state, max_age=300, **cookie_kwargs)
|
|
77
|
+
resp.set_cookie(nonce_cookie_name, nonce, max_age=300, **cookie_kwargs)
|
|
78
|
+
resp.set_cookie(next_cookie_name, next_path, max_age=300, **cookie_kwargs)
|
|
79
|
+
return resp
|
|
80
|
+
|
|
81
|
+
@router.get("/callback", name="oidc_callback")
|
|
82
|
+
def oidc_callback(
|
|
83
|
+
request: Request,
|
|
84
|
+
repo: Any = Depends(get_repo),
|
|
85
|
+
code: str | None = None,
|
|
86
|
+
state: str | None = None,
|
|
87
|
+
error: str | None = None,
|
|
88
|
+
error_description: str | None = None,
|
|
89
|
+
):
|
|
90
|
+
"""Finish OIDC login, persist a session, and set the session cookie."""
|
|
91
|
+
oidc.load_config(repo)
|
|
92
|
+
if not oidc.enabled:
|
|
93
|
+
raise HTTPException(
|
|
94
|
+
status_code=404,
|
|
95
|
+
detail="OIDC is disabled.",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if error:
|
|
99
|
+
desc = error_description or "OIDC authorization failed."
|
|
100
|
+
raise HTTPException(status_code=401, detail=f"{error}: {desc}")
|
|
101
|
+
|
|
102
|
+
expected_state = request.cookies.get(state_cookie_name)
|
|
103
|
+
expected_nonce = request.cookies.get(nonce_cookie_name)
|
|
104
|
+
next_path = safe_next_path(request.cookies.get(next_cookie_name))
|
|
105
|
+
|
|
106
|
+
if not code:
|
|
107
|
+
raise HTTPException(status_code=400, detail="Missing authorization code.")
|
|
108
|
+
if not state or not expected_state or state != expected_state:
|
|
109
|
+
raise HTTPException(status_code=401, detail="Invalid OIDC state.")
|
|
110
|
+
if not expected_nonce:
|
|
111
|
+
raise HTTPException(status_code=401, detail="Missing OIDC nonce.")
|
|
112
|
+
|
|
113
|
+
redirect_uri = oidc.config.redirect_uri or str(request.url_for("oidc_callback"))
|
|
114
|
+
token_payload = oidc.exchange_code(code, redirect_uri)
|
|
115
|
+
|
|
116
|
+
id_token = token_payload.get("id_token")
|
|
117
|
+
if not id_token or not isinstance(id_token, str):
|
|
118
|
+
raise HTTPException(
|
|
119
|
+
status_code=401,
|
|
120
|
+
detail="Token endpoint response missing id_token.",
|
|
121
|
+
)
|
|
122
|
+
refresh_token = token_payload.get("refresh_token")
|
|
123
|
+
refresh_token_value = (
|
|
124
|
+
refresh_token if isinstance(refresh_token, str) and refresh_token else None
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
claims = oidc.validate_jwt(
|
|
128
|
+
id_token,
|
|
129
|
+
expected_nonce=expected_nonce,
|
|
130
|
+
strict_client_audience=True,
|
|
131
|
+
)
|
|
132
|
+
oidc.ensure_authorized(claims)
|
|
133
|
+
session_id = secrets.token_urlsafe(32)
|
|
134
|
+
repo.create_oidc_session(
|
|
135
|
+
oidc.build_session_record(
|
|
136
|
+
session_id,
|
|
137
|
+
id_token=id_token,
|
|
138
|
+
refresh_token=refresh_token_value,
|
|
139
|
+
claims=claims,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
actor_id = str(claims.get(oidc.config.ui_username_claim) or claims.get("sub"))
|
|
143
|
+
log_auth_event(
|
|
144
|
+
repo,
|
|
145
|
+
actor_id,
|
|
146
|
+
"LOGIN",
|
|
147
|
+
{
|
|
148
|
+
"auth_type": "oidc",
|
|
149
|
+
"refresh_token_present": bool(refresh_token_value),
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
resp = RedirectResponse(next_path, status_code=302)
|
|
154
|
+
cookie_kwargs = oidc_cookie_kwargs()
|
|
155
|
+
if oidc.session_cookie_name:
|
|
156
|
+
resp.set_cookie(
|
|
157
|
+
oidc.session_cookie_name,
|
|
158
|
+
session_id,
|
|
159
|
+
max_age=oidc.config.session_max_age_seconds,
|
|
160
|
+
**cookie_kwargs,
|
|
161
|
+
)
|
|
162
|
+
resp.delete_cookie(
|
|
163
|
+
state_cookie_name,
|
|
164
|
+
path="/",
|
|
165
|
+
domain=oidc.config.cookie_domain,
|
|
166
|
+
)
|
|
167
|
+
resp.delete_cookie(
|
|
168
|
+
nonce_cookie_name,
|
|
169
|
+
path="/",
|
|
170
|
+
domain=oidc.config.cookie_domain,
|
|
171
|
+
)
|
|
172
|
+
resp.delete_cookie(
|
|
173
|
+
next_cookie_name,
|
|
174
|
+
path="/",
|
|
175
|
+
domain=oidc.config.cookie_domain,
|
|
176
|
+
)
|
|
177
|
+
return resp
|
|
178
|
+
|
|
179
|
+
@router.post("/logout")
|
|
180
|
+
def oidc_logout(
|
|
181
|
+
repo: Any = Depends(get_repo),
|
|
182
|
+
actor_id: str = Depends(get_audit_actor),
|
|
183
|
+
claims: dict[str, Any] = Security(require_authenticated),
|
|
184
|
+
):
|
|
185
|
+
"""Clear the OIDC session cookie and write a logout audit event."""
|
|
186
|
+
session_id = str(claims.get("_session_id") or "").strip()
|
|
187
|
+
if session_id:
|
|
188
|
+
repo.delete_oidc_session(session_id)
|
|
189
|
+
log_auth_event(
|
|
190
|
+
repo,
|
|
191
|
+
actor_id,
|
|
192
|
+
"LOGOUT",
|
|
193
|
+
{"auth_type": str(claims.get("auth_type") or "oidc")},
|
|
194
|
+
)
|
|
195
|
+
resp = Response(status_code=204)
|
|
196
|
+
if oidc.session_cookie_name:
|
|
197
|
+
resp.delete_cookie(
|
|
198
|
+
oidc.session_cookie_name,
|
|
199
|
+
path="/",
|
|
200
|
+
domain=oidc.config.cookie_domain,
|
|
201
|
+
)
|
|
202
|
+
return resp
|
|
203
|
+
|
|
204
|
+
@router.get("/me")
|
|
205
|
+
def oidc_me(
|
|
206
|
+
request: Request,
|
|
207
|
+
claims: dict[str, Any] = Security(require_authenticated),
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
"""Return the current caller's claims plus auth metadata."""
|
|
210
|
+
payload = oidc.enrich_claims(claims)
|
|
211
|
+
payload["cookies"] = request.cookies
|
|
212
|
+
return payload
|
|
213
|
+
|
|
214
|
+
return router
|
cpkit/auth/secrets.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Versioned symmetric secret encryption helpers."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
8
|
+
|
|
9
|
+
ENCRYPTED_SECRET_VERSION = b"\x01"
|
|
10
|
+
DEFAULT_MASTER_KEY_ENV_VAR = "CPKIT_MASTER_KEY"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_secret_crypto_config(
|
|
14
|
+
*,
|
|
15
|
+
master_key_env_var: str = DEFAULT_MASTER_KEY_ENV_VAR,
|
|
16
|
+
) -> None:
|
|
17
|
+
_secret_master_key(master_key_env_var=master_key_env_var)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def encrypt_secret(
|
|
21
|
+
secret: bytes | str,
|
|
22
|
+
*,
|
|
23
|
+
master_key_env_var: str = DEFAULT_MASTER_KEY_ENV_VAR,
|
|
24
|
+
) -> bytes:
|
|
25
|
+
nonce = secrets.token_bytes(12)
|
|
26
|
+
key = _secret_master_key(master_key_env_var=master_key_env_var)
|
|
27
|
+
ciphertext = AESGCM(key).encrypt(nonce, _secret_bytes(secret), None)
|
|
28
|
+
return ENCRYPTED_SECRET_VERSION + nonce + ciphertext
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decrypt_secret(
|
|
32
|
+
secret: bytes | str,
|
|
33
|
+
*,
|
|
34
|
+
master_key_env_var: str = DEFAULT_MASTER_KEY_ENV_VAR,
|
|
35
|
+
) -> bytes:
|
|
36
|
+
encrypted_secret = _secret_bytes(secret)
|
|
37
|
+
if not encrypted_secret:
|
|
38
|
+
raise RuntimeError("Encrypted secret is empty.")
|
|
39
|
+
if encrypted_secret[:1] != ENCRYPTED_SECRET_VERSION:
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
"Encrypted secret has an unsupported format. Migrate stored secrets to the versioned encrypted format."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
nonce = encrypted_secret[1:13]
|
|
45
|
+
ciphertext = encrypted_secret[13:]
|
|
46
|
+
if len(nonce) != 12 or not ciphertext:
|
|
47
|
+
raise RuntimeError("Encrypted secret is malformed.")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
return AESGCM(
|
|
51
|
+
_secret_master_key(master_key_env_var=master_key_env_var)
|
|
52
|
+
).decrypt(nonce, ciphertext, None)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
f"Encrypted secret could not be decrypted. Check {master_key_env_var} and stored key material."
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _secret_master_key(*, master_key_env_var: str) -> bytes:
|
|
60
|
+
encoded_key = os.getenv(master_key_env_var, "").strip()
|
|
61
|
+
if not encoded_key:
|
|
62
|
+
raise RuntimeError(f"{master_key_env_var} must be set for secret encryption.")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
key = base64.b64decode(encoded_key, validate=True)
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
raise RuntimeError(f"{master_key_env_var} must be valid base64.") from exc
|
|
68
|
+
|
|
69
|
+
if len(key) != 32:
|
|
70
|
+
raise RuntimeError(f"{master_key_env_var} must decode to exactly 32 bytes.")
|
|
71
|
+
|
|
72
|
+
return key
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _secret_bytes(secret: bytes | str) -> bytes:
|
|
76
|
+
if isinstance(secret, bytes):
|
|
77
|
+
return secret
|
|
78
|
+
return secret.encode("utf-8")
|
cpkit/auth/types.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Generic auth capability data types."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiKeyRecord(BaseModel):
|
|
10
|
+
access_key: str
|
|
11
|
+
encrypted_secret_access_key: bytes
|
|
12
|
+
owner: str
|
|
13
|
+
valid_until: dt.datetime
|
|
14
|
+
roles: list[Any] | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiKeySummary(BaseModel):
|
|
18
|
+
access_key: str
|
|
19
|
+
owner: str
|
|
20
|
+
valid_until: dt.datetime
|
|
21
|
+
roles: list[Any] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiKeyCreateRequest(BaseModel):
|
|
25
|
+
valid_until: dt.datetime
|
|
26
|
+
roles: list[Any] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiKeyCreateRequestInDB(ApiKeyCreateRequest):
|
|
30
|
+
access_key: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiKeyCreateResponse(ApiKeySummary):
|
|
34
|
+
secret_access_key: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OIDCSessionRecord(BaseModel):
|
|
38
|
+
session_id: str
|
|
39
|
+
encrypted_id_token: bytes
|
|
40
|
+
encrypted_refresh_token: bytes | None = None
|
|
41
|
+
token_expires_at: dt.datetime
|
|
42
|
+
session_expires_at: dt.datetime
|
|
43
|
+
created_at: dt.datetime | None = None
|
|
44
|
+
updated_at: dt.datetime | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RoleGroupMap(BaseModel):
|
|
48
|
+
role: str
|
|
49
|
+
groups: list[str]
|