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 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