auth51 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.
- auth51/__init__.py +47 -0
- auth51/adapters/__init__.py +0 -0
- auth51/adapters/langchain.py +61 -0
- auth51/authority.py +241 -0
- auth51/aws.py +155 -0
- auth51/config.py +59 -0
- auth51/context.py +181 -0
- auth51/identity.py +110 -0
- auth51/interceptor.py +394 -0
- auth51/mcp.py +154 -0
- auth51/obs.py +137 -0
- auth51/pop.py +109 -0
- auth51/providers.py +148 -0
- auth51/registry.py +96 -0
- auth51-0.1.0.dist-info/METADATA +67 -0
- auth51-0.1.0.dist-info/RECORD +17 -0
- auth51-0.1.0.dist-info/WHEEL +4 -0
auth51/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth51 — the in-process client (embed mode).
|
|
3
|
+
|
|
4
|
+
One import installs egress interception (DESIGN §7b: activation = one import).
|
|
5
|
+
It's a no-op until you `configure(...)` an authority + audiences AND wrap agent
|
|
6
|
+
work in `auth51.agent(...)`. Then every governed outbound call carries a scoped,
|
|
7
|
+
verifiable intent token — minted at the source, verified at the resource.
|
|
8
|
+
|
|
9
|
+
import auth51
|
|
10
|
+
auth51.configure(authority_url=..., client_id=..., client_secret=...,
|
|
11
|
+
audiences=["api.internal"])
|
|
12
|
+
with auth51.agent("ReviewBot", checksum=cs, scope="read:repo"):
|
|
13
|
+
httpx.get("https://api.internal/...") # ← stamped with an intent token
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .config import Config, configure, get_config, load_env
|
|
18
|
+
from .context import (
|
|
19
|
+
AgentContext, agent, current, current_derived, effective,
|
|
20
|
+
workflow, start_workflow, end_workflow, current_workflow, record_step,
|
|
21
|
+
)
|
|
22
|
+
from .identity import Candidate, DerivedIdentity, derive_identity
|
|
23
|
+
from .interceptor import install, uninstall, on_identity_event
|
|
24
|
+
from .obs import observe, current_obs
|
|
25
|
+
from .mcp import (
|
|
26
|
+
INTENT_META_KEY, build_intent_meta, exchange_intent, extract_intent,
|
|
27
|
+
incoming_intent, inject_intent, mint_and_inject, mint_for_tool,
|
|
28
|
+
scope_for_tool, serving,
|
|
29
|
+
)
|
|
30
|
+
from .registry import load_agents, registered_agents, agent_for_checksum, candidates
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Config", "configure", "get_config", "load_env",
|
|
34
|
+
"AgentContext", "agent", "current", "current_derived", "effective",
|
|
35
|
+
"workflow", "start_workflow", "end_workflow", "current_workflow", "record_step",
|
|
36
|
+
"Candidate", "DerivedIdentity", "derive_identity",
|
|
37
|
+
"install", "uninstall", "on_identity_event",
|
|
38
|
+
"observe", "current_obs",
|
|
39
|
+
"INTENT_META_KEY", "build_intent_meta", "extract_intent", "inject_intent",
|
|
40
|
+
"mint_and_inject", "mint_for_tool", "scope_for_tool",
|
|
41
|
+
"serving", "incoming_intent", "exchange_intent",
|
|
42
|
+
"load_agents", "registered_agents", "agent_for_checksum", "candidates",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# Activation = one import. Idempotent; pure passthrough until configured.
|
|
46
|
+
load_env()
|
|
47
|
+
install()
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LangChain adapter — set the auth51 agent context + record workflow steps at tool
|
|
3
|
+
boundaries, so `import auth51` + this callback handler = governed egress with no
|
|
4
|
+
other app changes. This generalizes the original clientshim's tool wrapper (which
|
|
5
|
+
set the agent contextvar and recorded steps) — minus the framework lock-in and
|
|
6
|
+
the call-stack introspection.
|
|
7
|
+
|
|
8
|
+
from langchain.agents import AgentExecutor
|
|
9
|
+
from auth51.adapters.langchain import Auth51CallbackHandler
|
|
10
|
+
|
|
11
|
+
handler = Auth51CallbackHandler(agent_id="ReviewBot", checksum=cs)
|
|
12
|
+
executor.invoke({...}, config={"callbacks": [handler]})
|
|
13
|
+
|
|
14
|
+
Import is lazy: this module requires `langchain_core`. The rest of auth51 works
|
|
15
|
+
without it.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
23
|
+
except ImportError as exc: # pragma: no cover
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"auth51.adapters.langchain requires langchain_core "
|
|
26
|
+
"(`pip install langchain-core`)."
|
|
27
|
+
) from exc
|
|
28
|
+
|
|
29
|
+
from ..context import AgentContext, _reset_agent, _set_agent, record_step
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Auth51CallbackHandler(BaseCallbackHandler):
|
|
33
|
+
"""Binds tool execution to an agent identity for auth51 egress stamping."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, agent_id: str, checksum: Optional[str] = None, scope: Optional[str] = None):
|
|
36
|
+
self.agent_id = agent_id
|
|
37
|
+
self.checksum = checksum
|
|
38
|
+
self.scope = scope
|
|
39
|
+
self._runs: Dict[Any, tuple] = {} # run_id -> (reset_token, tool_name)
|
|
40
|
+
|
|
41
|
+
def on_tool_start(self, serialized: Dict[str, Any], input_str: str, *, run_id=None, **kwargs) -> None:
|
|
42
|
+
tool = (serialized or {}).get("name") or kwargs.get("name") or ""
|
|
43
|
+
ctx = AgentContext(
|
|
44
|
+
agent_id=self.agent_id, checksum=self.checksum,
|
|
45
|
+
scope=self.scope, tool=tool, provenance="llm-declared",
|
|
46
|
+
)
|
|
47
|
+
token = _set_agent(ctx)
|
|
48
|
+
self._runs[run_id] = (token, tool)
|
|
49
|
+
record_step(self.agent_id, tool, "started")
|
|
50
|
+
|
|
51
|
+
def on_tool_end(self, output: Any, *, run_id=None, **kwargs) -> None:
|
|
52
|
+
token, tool = self._runs.pop(run_id, (None, ""))
|
|
53
|
+
record_step(self.agent_id, tool, "completed")
|
|
54
|
+
if token is not None:
|
|
55
|
+
_reset_agent(token)
|
|
56
|
+
|
|
57
|
+
def on_tool_error(self, error: BaseException, *, run_id=None, **kwargs) -> None:
|
|
58
|
+
token, tool = self._runs.pop(run_id, (None, ""))
|
|
59
|
+
record_step(self.agent_id, tool, "failed", error=str(error))
|
|
60
|
+
if token is not None:
|
|
61
|
+
_reset_agent(token)
|
auth51/authority.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authority client — mints intent tokens. Uses stdlib urllib (NOT httpx/requests)
|
|
3
|
+
on purpose: those are the libraries the interceptor patches, so minting via them
|
|
4
|
+
would re-enter the interceptor (infinite recursion). urllib sidesteps that
|
|
5
|
+
entirely and keeps the core dependency-free.
|
|
6
|
+
|
|
7
|
+
Contract mirrors auth51-contracts (the authority OpenAPI): client_credentials →
|
|
8
|
+
OAuth token carrying `generate:intent-token` + delegated scope, then
|
|
9
|
+
POST /v1/intent/token with grant_type=agent_checksum.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Dict, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from .config import get_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthorityError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# OAuth client_credentials tokens cached per scope-set (mirrors the proxy).
|
|
27
|
+
_oauth_cache: Dict[str, Tuple[str, float]] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _post_form(url: str, data: dict, headers: Optional[dict] = None) -> dict:
|
|
31
|
+
body = urllib.parse.urlencode(data).encode()
|
|
32
|
+
req = urllib.request.Request(
|
|
33
|
+
url, data=body, method="POST",
|
|
34
|
+
headers={"content-type": "application/x-www-form-urlencoded", **(headers or {})},
|
|
35
|
+
)
|
|
36
|
+
with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310 (trusted authority URL)
|
|
37
|
+
return json.loads(r.read().decode())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _post_json(url: str, obj: dict, headers: Optional[dict] = None) -> dict:
|
|
41
|
+
body = json.dumps(obj).encode()
|
|
42
|
+
req = urllib.request.Request(
|
|
43
|
+
url, data=body, method="POST",
|
|
44
|
+
headers={"content-type": "application/json", **(headers or {})},
|
|
45
|
+
)
|
|
46
|
+
with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
|
|
47
|
+
return json.loads(r.read().decode())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_json(url: str, headers: Optional[dict] = None) -> dict:
|
|
51
|
+
req = urllib.request.Request(url, method="GET", headers=headers or {})
|
|
52
|
+
with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
|
|
53
|
+
return json.loads(r.read().decode())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_oauth_token(full_scope: str) -> str:
|
|
57
|
+
"""An auth51 OAuth token for an exact scope string, cached per scope-set.
|
|
58
|
+
|
|
59
|
+
Keyed path (on-prem / local / anywhere with a secret): client_credentials
|
|
60
|
+
using the configured client_id/client_secret. Keyless path (cloud): if no
|
|
61
|
+
secret is configured, prove the runtime's ambient workload identity (AWS
|
|
62
|
+
today) and let the authority mint — no stored secret. See
|
|
63
|
+
DESIGN-keyless-workload-identity.md."""
|
|
64
|
+
cfg = get_config()
|
|
65
|
+
now = time.time()
|
|
66
|
+
cached = _oauth_cache.get(full_scope)
|
|
67
|
+
if cached and now < cached[1] - 5:
|
|
68
|
+
return cached[0]
|
|
69
|
+
if cfg.client_id and cfg.client_secret:
|
|
70
|
+
data = _post_form(
|
|
71
|
+
cfg.authority_url.rstrip("/") + "/v1/oauth/token",
|
|
72
|
+
{
|
|
73
|
+
"grant_type": "client_credentials",
|
|
74
|
+
"client_id": cfg.client_id,
|
|
75
|
+
"client_secret": cfg.client_secret,
|
|
76
|
+
"scope": full_scope,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
data = _acquire_workload_token(full_scope)
|
|
81
|
+
tok = data.get("access_token")
|
|
82
|
+
if not tok:
|
|
83
|
+
raise AuthorityError("OAuth token endpoint returned no access_token")
|
|
84
|
+
_oauth_cache[full_scope] = (tok, now + float(data.get("expires_in", 300)))
|
|
85
|
+
return tok
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _acquire_workload_token(full_scope: str) -> dict:
|
|
89
|
+
"""Keyless: sign an ambient AWS identity proof and exchange it for an auth51
|
|
90
|
+
token at /workload/aws-token. The issued token is DPoP-bound to our PoP key,
|
|
91
|
+
so a replayed GetCallerIdentity would yield a token the replayer can't use."""
|
|
92
|
+
from . import aws, pop # local imports: keep authority import-light
|
|
93
|
+
|
|
94
|
+
cfg = get_config()
|
|
95
|
+
cnf = pop.jkt() if cfg.pop_enabled else None
|
|
96
|
+
payload = aws.signed_identity_payload(cnf_jkt=cnf)
|
|
97
|
+
if payload is None:
|
|
98
|
+
raise AuthorityError(
|
|
99
|
+
"no auth51 credentials: set AUTH51_CLIENT_ID/SECRET, or run on a "
|
|
100
|
+
"registered cloud workload identity (AWS role)."
|
|
101
|
+
)
|
|
102
|
+
payload["scope"] = full_scope
|
|
103
|
+
return _post_json(cfg.authority_url.rstrip("/") + "/v1/workload/aws-token", payload)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def mint_intent(
|
|
107
|
+
*,
|
|
108
|
+
agent_id: str,
|
|
109
|
+
checksum: Optional[str],
|
|
110
|
+
scope: str,
|
|
111
|
+
audience: str,
|
|
112
|
+
workflow_id: Optional[str] = None,
|
|
113
|
+
workflow_step: Optional[dict] = None,
|
|
114
|
+
delegation_context: Optional[dict] = None,
|
|
115
|
+
cnf_jkt: Optional[str] = None,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Mint a scoped, short-lived intent token for one agent action (Hop A).
|
|
119
|
+
Optional workflow_id/step/delegation_context ride in for provenance (§4.3);
|
|
120
|
+
workflow_enabled stays False (we don't use the authority's DAG authz path).
|
|
121
|
+
cnf_jkt binds the token to the caller's ephemeral PoP key (DPoP / RFC 9449).
|
|
122
|
+
"""
|
|
123
|
+
cfg = get_config()
|
|
124
|
+
# The OAuth client_credentials token only needs the mint capability
|
|
125
|
+
# (generate:intent-token). The delegated scope goes in the mint body's
|
|
126
|
+
# requested_scopes and is governed by the agent GRANT — NOT the OAuth token
|
|
127
|
+
# (the authority does not require requested ⊆ OAuth scopes; the grant is the
|
|
128
|
+
# ceiling). Folding the delegated scope into the OAuth request would 400 for
|
|
129
|
+
# any scope this OAuth client isn't explicitly granted.
|
|
130
|
+
oauth = get_oauth_token("generate:intent-token")
|
|
131
|
+
payload = {
|
|
132
|
+
"grant_type": "agent_checksum",
|
|
133
|
+
"agent_id": agent_id,
|
|
134
|
+
"computed_checksum": checksum or "",
|
|
135
|
+
"requested_scopes": scope.split() if scope else [],
|
|
136
|
+
"audience": audience,
|
|
137
|
+
"workflow_enabled": False,
|
|
138
|
+
}
|
|
139
|
+
if workflow_id:
|
|
140
|
+
payload["workflow_id"] = workflow_id
|
|
141
|
+
if workflow_step:
|
|
142
|
+
payload["workflow_step"] = workflow_step
|
|
143
|
+
if delegation_context:
|
|
144
|
+
payload["delegation_context"] = delegation_context
|
|
145
|
+
if cnf_jkt:
|
|
146
|
+
payload["cnf_jkt"] = cnf_jkt
|
|
147
|
+
mint_url = cfg.authority_url.rstrip("/") + "/v1/intent/token"
|
|
148
|
+
data = _post_json(mint_url, payload, headers={"authorization": f"Bearer {oauth}"})
|
|
149
|
+
tok = data.get("access_token")
|
|
150
|
+
if not tok:
|
|
151
|
+
raise AuthorityError("intent mint returned no access_token")
|
|
152
|
+
# Record the exchange for the live viewer (observability). The Authorization
|
|
153
|
+
# bearer is intentionally omitted (it's a client-credentials secret-equivalent);
|
|
154
|
+
# the request body + response are the transparency-relevant parts.
|
|
155
|
+
global _last_mint_exchange
|
|
156
|
+
_last_mint_exchange = {
|
|
157
|
+
"request": {"method": "POST", "url": mint_url, "body": payload},
|
|
158
|
+
"response": data,
|
|
159
|
+
}
|
|
160
|
+
return tok
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_last_mint_exchange: Optional[dict] = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def last_mint_exchange() -> Optional[dict]:
|
|
167
|
+
"""The most recent mint request/response (for the live viewer). Per-process,
|
|
168
|
+
overwritten each mint; never includes the OAuth bearer."""
|
|
169
|
+
return _last_mint_exchange
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_JWT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
|
|
173
|
+
_TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def exchange_token(
|
|
177
|
+
subject_token: str, *, audience: str, scope: Optional[str] = None
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Hop B (RFC 8693): exchange an intent token (subject) for a downstream
|
|
180
|
+
token scoped/audienced for the RS. Used at an MCP server's egress. The
|
|
181
|
+
authority verifies the subject_token against its trusted-issuer config —
|
|
182
|
+
so the authority must trust its own issuer for self-issued intent tokens.
|
|
183
|
+
"""
|
|
184
|
+
cfg = get_config()
|
|
185
|
+
data = {
|
|
186
|
+
"grant_type": _TOKEN_EXCHANGE_GRANT,
|
|
187
|
+
"subject_token": subject_token,
|
|
188
|
+
"subject_token_type": _JWT_TOKEN_TYPE,
|
|
189
|
+
"audience": audience,
|
|
190
|
+
}
|
|
191
|
+
if scope:
|
|
192
|
+
data["scope"] = scope
|
|
193
|
+
out = _post_form(cfg.authority_url.rstrip("/") + "/v1/oauth/token", data)
|
|
194
|
+
tok = out.get("access_token")
|
|
195
|
+
if not tok:
|
|
196
|
+
raise AuthorityError("token exchange returned no access_token")
|
|
197
|
+
return tok
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def exchange_session(
|
|
201
|
+
session_token: str,
|
|
202
|
+
*,
|
|
203
|
+
audience: str,
|
|
204
|
+
scope: Optional[str] = None,
|
|
205
|
+
bind_pop: bool = True,
|
|
206
|
+
) -> str:
|
|
207
|
+
"""Exchange an EXTERNAL session token — issued by the application's own IDP
|
|
208
|
+
(a trusted issuer registered at the authority: Okta/Auth0/Entra/the legacy
|
|
209
|
+
patchet IDP/…) — for a sender-constrained auth51 OAuth token (RFC 8693).
|
|
210
|
+
|
|
211
|
+
Two properties close the A2 gap (a stolen session token being enough):
|
|
212
|
+
* we authenticate with the embed's CLIENT credential, so the session token
|
|
213
|
+
alone cannot be exchanged (the issuer is marked require_client_auth);
|
|
214
|
+
* the issued token is DPoP-bound to the ephemeral PoP key (cnf.jkt), so it
|
|
215
|
+
cannot be replayed — the same key then proves possession when this token
|
|
216
|
+
mints intent tokens.
|
|
217
|
+
The returned token is the auth51 OAuth token used in place of a raw external
|
|
218
|
+
session for `generate:intent-token`.
|
|
219
|
+
"""
|
|
220
|
+
from . import pop # local import: pop has no dep on authority, avoid cycle
|
|
221
|
+
|
|
222
|
+
cfg = get_config()
|
|
223
|
+
data = {
|
|
224
|
+
"grant_type": _TOKEN_EXCHANGE_GRANT,
|
|
225
|
+
"subject_token": session_token,
|
|
226
|
+
"subject_token_type": _JWT_TOKEN_TYPE,
|
|
227
|
+
"audience": audience,
|
|
228
|
+
"client_id": cfg.client_id or "",
|
|
229
|
+
"client_secret": cfg.client_secret or "",
|
|
230
|
+
}
|
|
231
|
+
if scope:
|
|
232
|
+
data["scope"] = scope
|
|
233
|
+
if bind_pop:
|
|
234
|
+
jkt = pop.jkt()
|
|
235
|
+
if jkt:
|
|
236
|
+
data["cnf_jkt"] = jkt
|
|
237
|
+
out = _post_form(cfg.authority_url.rstrip("/") + "/v1/oauth/token", data)
|
|
238
|
+
tok = out.get("access_token")
|
|
239
|
+
if not tok:
|
|
240
|
+
raise AuthorityError("session exchange returned no access_token")
|
|
241
|
+
return tok
|
auth51/aws.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ambient AWS identity → a signed sts:GetCallerIdentity payload for the authority's
|
|
3
|
+
keyless `/workload/aws-token` endpoint. No boto3: credential discovery + SigV4 are
|
|
4
|
+
pure stdlib, so the embed stays dependency-light.
|
|
5
|
+
|
|
6
|
+
Discovery order (matches how the AWS SDK resolves a workload's role):
|
|
7
|
+
1. env — AWS_ACCESS_KEY_ID / _SECRET_ACCESS_KEY / _SESSION_TOKEN
|
|
8
|
+
2. ECS — the container credentials endpoint (Fargate / ECS task role)
|
|
9
|
+
3. EC2 — IMDSv2 instance-profile role
|
|
10
|
+
|
|
11
|
+
The signed request proves the STABLE role behind these temp creds (STS returns the
|
|
12
|
+
role ARN); the ephemeral keys never leave the process except as a signature AWS
|
|
13
|
+
itself validates. See DESIGN-keyless-workload-identity.md §2.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import datetime as _dt
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import urllib.request
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
# Must equal the authority's AUTH51_AWS_SERVER_ID — binds the signature to auth51
|
|
28
|
+
# so a GetCallerIdentity signed for us can't be replayed elsewhere (and vice versa).
|
|
29
|
+
SERVER_ID = os.getenv("AUTH51_AWS_SERVER_ID", "authority.auth51.com")
|
|
30
|
+
SERVER_ID_HEADER = "X-Auth51-Server-Id"
|
|
31
|
+
_IMDS = "http://169.254.169.254"
|
|
32
|
+
_ECS_HOST = "http://169.254.170.2"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class _Creds:
|
|
37
|
+
access_key: str
|
|
38
|
+
secret_key: str
|
|
39
|
+
token: Optional[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get(url: str, headers: Optional[dict] = None, method: str = "GET", timeout: float = 1.0):
|
|
43
|
+
req = urllib.request.Request(url, headers=headers or {}, method=method)
|
|
44
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (link-local)
|
|
45
|
+
return r.read().decode()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def discover_credentials() -> Optional[_Creds]:
|
|
49
|
+
"""Return ambient AWS creds, or None if this isn't running on AWS."""
|
|
50
|
+
ak, sk = os.getenv("AWS_ACCESS_KEY_ID"), os.getenv("AWS_SECRET_ACCESS_KEY")
|
|
51
|
+
if ak and sk:
|
|
52
|
+
return _Creds(ak, sk, os.getenv("AWS_SESSION_TOKEN"))
|
|
53
|
+
|
|
54
|
+
# ECS / Fargate task role — the SDK's container credentials provider.
|
|
55
|
+
rel = os.getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
|
|
56
|
+
full = os.getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI")
|
|
57
|
+
try:
|
|
58
|
+
if rel or full:
|
|
59
|
+
url = full or (_ECS_HOST + rel)
|
|
60
|
+
hdr = {}
|
|
61
|
+
tok_file = os.getenv("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE")
|
|
62
|
+
if tok_file and os.path.exists(tok_file):
|
|
63
|
+
with open(tok_file) as f:
|
|
64
|
+
hdr["Authorization"] = f.read().strip()
|
|
65
|
+
d = json.loads(_get(url, headers=hdr, timeout=2.0))
|
|
66
|
+
return _Creds(d["AccessKeyId"], d["SecretAccessKey"], d.get("Token"))
|
|
67
|
+
except Exception: # noqa: BLE001 — fall through to IMDS / None
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# EC2 IMDSv2 instance profile.
|
|
71
|
+
try:
|
|
72
|
+
token = _get(_IMDS + "/latest/api/token", method="PUT",
|
|
73
|
+
headers={"X-aws-ec2-metadata-token-ttl-seconds": "60"})
|
|
74
|
+
h = {"X-aws-ec2-metadata-token": token}
|
|
75
|
+
base = _IMDS + "/latest/meta-data/iam/security-credentials/"
|
|
76
|
+
role = _get(base, headers=h).strip().splitlines()[0]
|
|
77
|
+
d = json.loads(_get(base + role, headers=h))
|
|
78
|
+
return _Creds(d["AccessKeyId"], d["SecretAccessKey"], d.get("Token"))
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sign(key: bytes, msg: str) -> bytes:
|
|
84
|
+
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def signed_identity_payload(cnf_jkt: Optional[str] = None) -> Optional[dict]:
|
|
88
|
+
"""Build the SigV4-signed GetCallerIdentity payload for /workload/aws-token,
|
|
89
|
+
or None if no ambient AWS identity is available. `cnf_jkt` (the DPoP key
|
|
90
|
+
thumbprint) is passed through so the issued token is sender-constrained."""
|
|
91
|
+
creds = discover_credentials()
|
|
92
|
+
if creds is None:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
region = os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") or "us-east-1"
|
|
96
|
+
host = f"sts.{region}.amazonaws.com"
|
|
97
|
+
body = "Action=GetCallerIdentity&Version=2011-06-15"
|
|
98
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
99
|
+
amzdate = now.strftime("%Y%m%dT%H%M%SZ")
|
|
100
|
+
datestamp = now.strftime("%Y%m%d")
|
|
101
|
+
|
|
102
|
+
# Canonical headers — sorted, lowercased. x-auth51-server-id MUST be signed.
|
|
103
|
+
headers = {
|
|
104
|
+
"content-type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
105
|
+
"host": host,
|
|
106
|
+
"x-amz-date": amzdate,
|
|
107
|
+
SERVER_ID_HEADER.lower(): SERVER_ID,
|
|
108
|
+
}
|
|
109
|
+
if creds.token:
|
|
110
|
+
headers["x-amz-security-token"] = creds.token
|
|
111
|
+
signed_keys = sorted(headers)
|
|
112
|
+
canonical_headers = "".join(f"{k}:{headers[k]}\n" for k in signed_keys)
|
|
113
|
+
signed_headers = ";".join(signed_keys)
|
|
114
|
+
payload_hash = hashlib.sha256(body.encode()).hexdigest()
|
|
115
|
+
canonical_request = (
|
|
116
|
+
f"POST\n/\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
scope = f"{datestamp}/{region}/sts/aws4_request"
|
|
120
|
+
string_to_sign = (
|
|
121
|
+
"AWS4-HMAC-SHA256\n" + amzdate + "\n" + scope + "\n"
|
|
122
|
+
+ hashlib.sha256(canonical_request.encode()).hexdigest()
|
|
123
|
+
)
|
|
124
|
+
k_date = _sign(("AWS4" + creds.secret_key).encode(), datestamp)
|
|
125
|
+
k_region = _sign(k_date, region)
|
|
126
|
+
k_service = _sign(k_region, "sts")
|
|
127
|
+
k_signing = _sign(k_service, "aws4_request")
|
|
128
|
+
signature = hmac.new(k_signing, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
|
129
|
+
authorization = (
|
|
130
|
+
f"AWS4-HMAC-SHA256 Credential={creds.access_key}/{scope}, "
|
|
131
|
+
f"SignedHeaders={signed_headers}, Signature={signature}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
wire_headers = {
|
|
135
|
+
"Authorization": authorization,
|
|
136
|
+
"X-Amz-Date": amzdate,
|
|
137
|
+
SERVER_ID_HEADER: SERVER_ID,
|
|
138
|
+
"Host": host,
|
|
139
|
+
"Content-Type": headers["content-type"],
|
|
140
|
+
}
|
|
141
|
+
if creds.token:
|
|
142
|
+
wire_headers["X-Amz-Security-Token"] = creds.token
|
|
143
|
+
|
|
144
|
+
def _b64(s: str) -> str:
|
|
145
|
+
return base64.b64encode(s.encode()).decode()
|
|
146
|
+
|
|
147
|
+
out = {
|
|
148
|
+
"iam_http_request_method": "POST",
|
|
149
|
+
"iam_request_url": _b64(f"https://{host}/"),
|
|
150
|
+
"iam_request_body": _b64(body),
|
|
151
|
+
"iam_request_headers": _b64(json.dumps(wire_headers)),
|
|
152
|
+
}
|
|
153
|
+
if cnf_jkt:
|
|
154
|
+
out["cnf_jkt"] = cnf_jkt
|
|
155
|
+
return out
|
auth51/config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Configuration for the embed client. Set via auth51.configure(...) or env."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional, Set
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Config:
|
|
11
|
+
# Defaults to the managed SaaS authority; override only for self-hosted.
|
|
12
|
+
authority_url: Optional[str] = "https://authority.auth51.com"
|
|
13
|
+
app_id: Optional[str] = None # for load_agents() startup-sync
|
|
14
|
+
client_id: Optional[str] = None
|
|
15
|
+
client_secret: Optional[str] = None
|
|
16
|
+
# Destination hosts to govern. Egress to a host NOT in this set is passed
|
|
17
|
+
# through untouched — so an unconfigured client is a pure no-op.
|
|
18
|
+
audiences: Set[str] = field(default_factory=set)
|
|
19
|
+
header: str = "Authorization"
|
|
20
|
+
scheme: str = "Bearer"
|
|
21
|
+
# Fail-CLOSED by default: if minting fails, BLOCK the egress rather than
|
|
22
|
+
# forward it unauthenticated. This is the safe posture (and what high-assurance
|
|
23
|
+
# deployments expect). Set True (or AUTH51_FAIL_OPEN=1) to fail-open — forward
|
|
24
|
+
# untokenized on mint failure — only where availability must trump enforcement.
|
|
25
|
+
fail_open: bool = False
|
|
26
|
+
# Proof-of-Possession (DPoP / RFC 9449): bind tokens to an ephemeral key and
|
|
27
|
+
# attach a per-request proof. No-op if `cryptography`+`pyjwt` aren't installed.
|
|
28
|
+
pop_enabled: bool = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_config = Config()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def configure(**kwargs) -> Config:
|
|
35
|
+
for k, v in kwargs.items():
|
|
36
|
+
if not hasattr(_config, k):
|
|
37
|
+
raise AttributeError(f"unknown config key: {k}")
|
|
38
|
+
setattr(_config, k, set(v) if k == "audiences" else v)
|
|
39
|
+
return _config
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_config() -> Config:
|
|
43
|
+
return _config
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_env() -> Config:
|
|
47
|
+
_config.authority_url = os.getenv("AUTH51_AUTHORITY_URL", _config.authority_url)
|
|
48
|
+
_config.client_id = os.getenv("AUTH51_CLIENT_ID", _config.client_id)
|
|
49
|
+
_config.client_secret = os.getenv("AUTH51_CLIENT_SECRET", _config.client_secret)
|
|
50
|
+
aud = os.getenv("AUTH51_AUDIENCES")
|
|
51
|
+
if aud:
|
|
52
|
+
_config.audiences = {a.strip() for a in aud.split(",") if a.strip()}
|
|
53
|
+
fo = os.getenv("AUTH51_FAIL_OPEN")
|
|
54
|
+
if fo is not None:
|
|
55
|
+
_config.fail_open = fo.strip().lower() not in ("0", "false", "no")
|
|
56
|
+
pe = os.getenv("AUTH51_POP_ENABLED")
|
|
57
|
+
if pe is not None:
|
|
58
|
+
_config.pop_enabled = pe.strip().lower() not in ("0", "false", "no")
|
|
59
|
+
return _config
|