auth-md 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.
- auth_md-0.1.0/LICENSE +21 -0
- auth_md-0.1.0/PKG-INFO +40 -0
- auth_md-0.1.0/README.md +28 -0
- auth_md-0.1.0/auth_md/__init__.py +191 -0
- auth_md-0.1.0/auth_md.egg-info/PKG-INFO +40 -0
- auth_md-0.1.0/auth_md.egg-info/SOURCES.txt +10 -0
- auth_md-0.1.0/auth_md.egg-info/dependency_links.txt +1 -0
- auth_md-0.1.0/auth_md.egg-info/requires.txt +1 -0
- auth_md-0.1.0/auth_md.egg-info/top_level.txt +1 -0
- auth_md-0.1.0/pyproject.toml +13 -0
- auth_md-0.1.0/setup.cfg +4 -0
- auth_md-0.1.0/tests/test_basic.py +78 -0
auth_md-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Whosent
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
auth_md-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auth-md
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal helpers for the auth.md agent-registration protocol: discovery metadata, auth.md generation, and ID-JAG verification.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: auth.md,auth-md,agent-auth,ai-agents,id-jag,oauth,agent-registration
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyjwt[crypto]>=2.6
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# auth-md
|
|
14
|
+
|
|
15
|
+
_Community implementation, not affiliated with WorkOS._
|
|
16
|
+
|
|
17
|
+
Minimal helpers for the [auth.md](https://github.com/workos/auth.md) agent-registration protocol.
|
|
18
|
+
|
|
19
|
+
> Early release (0.1.0). Tracks the workos/auth.md agent-services guide as of October 2026. The spec is still evolving.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install auth-md
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Includes `protected_resource_metadata`, `authorization_server_metadata`, `www_authenticate`, `generate_auth_md`, and `verify_id_jag` (trust list, audience, `typ`, asymmetric algorithms only, `client_id`, verified email or phone, `auth_time` freshness, `jti` replay protection).
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from auth_md import verify_id_jag, MemoryJtiStore
|
|
29
|
+
|
|
30
|
+
identity = verify_id_jag(
|
|
31
|
+
assertion,
|
|
32
|
+
audience="https://auth.yourapp.com",
|
|
33
|
+
trusted_issuers={"https://provider.example": {"display_name": "Provider"}},
|
|
34
|
+
jti_store=MemoryJtiStore(), # use Redis or a DB table in production
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Never bind a verified ID-JAG to an existing account by email alone; the spec requires a first-link step-up.
|
|
39
|
+
|
|
40
|
+
MIT licensed.
|
auth_md-0.1.0/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# auth-md
|
|
2
|
+
|
|
3
|
+
_Community implementation, not affiliated with WorkOS._
|
|
4
|
+
|
|
5
|
+
Minimal helpers for the [auth.md](https://github.com/workos/auth.md) agent-registration protocol.
|
|
6
|
+
|
|
7
|
+
> Early release (0.1.0). Tracks the workos/auth.md agent-services guide as of October 2026. The spec is still evolving.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install auth-md
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Includes `protected_resource_metadata`, `authorization_server_metadata`, `www_authenticate`, `generate_auth_md`, and `verify_id_jag` (trust list, audience, `typ`, asymmetric algorithms only, `client_id`, verified email or phone, `auth_time` freshness, `jti` replay protection).
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from auth_md import verify_id_jag, MemoryJtiStore
|
|
17
|
+
|
|
18
|
+
identity = verify_id_jag(
|
|
19
|
+
assertion,
|
|
20
|
+
audience="https://auth.yourapp.com",
|
|
21
|
+
trusted_issuers={"https://provider.example": {"display_name": "Provider"}},
|
|
22
|
+
jti_store=MemoryJtiStore(), # use Redis or a DB table in production
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Never bind a verified ID-JAG to an existing account by email alone; the spec requires a first-link step-up.
|
|
27
|
+
|
|
28
|
+
MIT licensed.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""auth-md: minimal helpers for the auth.md agent-registration protocol.
|
|
2
|
+
|
|
3
|
+
Community implementation, not affiliated with WorkOS. Tracks the workos/auth.md
|
|
4
|
+
agent-services guide as of October 2026; the spec is still evolving.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import jwt
|
|
10
|
+
from jwt import PyJWKClient, PyJWKSet
|
|
11
|
+
|
|
12
|
+
__all__ = ["protected_resource_metadata", "authorization_server_metadata", "www_authenticate",
|
|
13
|
+
"generate_auth_md", "verify_id_jag", "MemoryJtiStore", "AgentAuthError"]
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
ID_JAG_TYP = "oauth-id-jag+jwt"
|
|
17
|
+
ID_JAG_ASSERTION_TYPE = "urn:ietf:params:oauth:token-type:id-jag"
|
|
18
|
+
CLAIM_GRANT_TYPE = "urn:workos:agent-auth:grant-type:claim"
|
|
19
|
+
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
20
|
+
ASSERTION_REVOKED_EVENT = "https://schemas.workos.com/events/agent/auth/identity/assertion/revoked"
|
|
21
|
+
# Asymmetric algorithms only. Never accept "none" or HMAC for third-party assertions.
|
|
22
|
+
ALLOWED_ALGS = ["RS256", "PS256", "ES256", "EdDSA"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentAuthError(Exception):
|
|
26
|
+
"""Carries a spec error code (invalid_issuer, replay_detected, login_required, ...)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, code, message, status=400):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.code = code
|
|
31
|
+
self.status = status
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def protected_resource_metadata(*, resource, authorization_servers, scopes_supported=(), resource_name=None):
|
|
35
|
+
"""Protected Resource Metadata (RFC 9728) for /.well-known/oauth-protected-resource."""
|
|
36
|
+
if not resource or not authorization_servers:
|
|
37
|
+
raise ValueError("resource and authorization_servers are required")
|
|
38
|
+
doc = {"resource": resource, "authorization_servers": list(authorization_servers),
|
|
39
|
+
"scopes_supported": list(scopes_supported), "bearer_methods_supported": ["header"]}
|
|
40
|
+
if resource_name:
|
|
41
|
+
doc["resource_name"] = resource_name
|
|
42
|
+
return doc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def authorization_server_metadata(*, issuer, token_endpoint, auth_md_url, identity_endpoint,
|
|
46
|
+
revocation_endpoint=None, claim_endpoint=None, events_endpoint=None,
|
|
47
|
+
identity_types=("identity_assertion", "service_auth"), scopes_supported=()):
|
|
48
|
+
"""Authorization Server metadata (RFC 8414) with the agent_auth block,
|
|
49
|
+
for /.well-known/oauth-authorization-server."""
|
|
50
|
+
for name, value in {"issuer": issuer, "token_endpoint": token_endpoint,
|
|
51
|
+
"auth_md_url": auth_md_url, "identity_endpoint": identity_endpoint}.items():
|
|
52
|
+
if not value:
|
|
53
|
+
raise ValueError(f"missing {name}")
|
|
54
|
+
claim_flows = "service_auth" in identity_types or "anonymous" in identity_types
|
|
55
|
+
agent_auth = {"skill": auth_md_url, "identity_endpoint": identity_endpoint,
|
|
56
|
+
"identity_types_supported": list(identity_types)}
|
|
57
|
+
if claim_endpoint:
|
|
58
|
+
agent_auth["claim_endpoint"] = claim_endpoint
|
|
59
|
+
if events_endpoint:
|
|
60
|
+
agent_auth["events_endpoint"] = events_endpoint
|
|
61
|
+
agent_auth["events_supported"] = [ASSERTION_REVOKED_EVENT]
|
|
62
|
+
if "identity_assertion" in identity_types:
|
|
63
|
+
agent_auth["identity_assertion"] = {"assertion_types_supported": [ID_JAG_ASSERTION_TYPE]}
|
|
64
|
+
doc = {"issuer": issuer, "token_endpoint": token_endpoint, "scopes_supported": list(scopes_supported),
|
|
65
|
+
"grant_types_supported": [JWT_BEARER_GRANT_TYPE] + ([CLAIM_GRANT_TYPE] if claim_flows else []),
|
|
66
|
+
"agent_auth": agent_auth}
|
|
67
|
+
if revocation_endpoint:
|
|
68
|
+
doc["revocation_endpoint"] = revocation_endpoint
|
|
69
|
+
return doc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def www_authenticate(resource_metadata_url):
|
|
73
|
+
"""WWW-Authenticate value for 401s so agents can discover your metadata."""
|
|
74
|
+
return f'Bearer resource_metadata="{resource_metadata_url}"'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate_auth_md(*, service_name, resource_metadata_url, identity_endpoint, token_endpoint,
|
|
78
|
+
identity_types=("identity_assertion", "service_auth"), scopes=()):
|
|
79
|
+
"""Generate a minimal, LLM-readable auth.md pointing agents at your discovery documents."""
|
|
80
|
+
if not all([service_name, resource_metadata_url, identity_endpoint, token_endpoint]):
|
|
81
|
+
raise ValueError("service_name, resource_metadata_url, identity_endpoint and token_endpoint are required")
|
|
82
|
+
lines = ["# auth.md", "", f"Instructions for AI agents registering with {service_name} on behalf of a user.", "",
|
|
83
|
+
"## 1. Discover", "",
|
|
84
|
+
f"Fetch `{resource_metadata_url}`, then the authorization server metadata it points to. "
|
|
85
|
+
"The `agent_auth` block lists the endpoints below.", "",
|
|
86
|
+
"## 2. Register", "", f"POST JSON to `{identity_endpoint}` with one of these `type` values:", ""]
|
|
87
|
+
if "identity_assertion" in identity_types:
|
|
88
|
+
lines.append(f'- `identity_assertion`: send `assertion_type: "{ID_JAG_ASSERTION_TYPE}"` and an ID-JAG '
|
|
89
|
+
"from your provider minted for this service.")
|
|
90
|
+
if "service_auth" in identity_types:
|
|
91
|
+
lines.append("- `service_auth`: send `login_hint` with the user's email. Show the user the returned "
|
|
92
|
+
"`user_code` and `verification_uri`.")
|
|
93
|
+
if "anonymous" in identity_types:
|
|
94
|
+
lines.append("- `anonymous`: no identity needed. You get limited scopes until a human claims the registration.")
|
|
95
|
+
lines += ["", "## 3. Get an access token", "",
|
|
96
|
+
f"Exchange the returned `identity_assertion` at `{token_endpoint}` with `grant_type={JWT_BEARER_GRANT_TYPE}`. "
|
|
97
|
+
f"During a claim ceremony, poll the same endpoint with `grant_type={CLAIM_GRANT_TYPE}` and your "
|
|
98
|
+
"`claim_token`, honoring `interval`.", "", "## 4. Scopes", ""]
|
|
99
|
+
lines += [f"- `{s}`" for s in scopes] or ["- Default scopes only."]
|
|
100
|
+
lines += ["", "## 5. Errors", "",
|
|
101
|
+
"- `authorization_pending`: keep polling. `slow_down`: poll less often. `expired_token`: start a new claim.",
|
|
102
|
+
"- `login_required`: refresh the user's login at your provider and mint a fresh ID-JAG.",
|
|
103
|
+
"- On a 401 from a previously working token, re-exchange your identity_assertion or restart registration.", ""]
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MemoryJtiStore:
|
|
108
|
+
"""In-memory jti store for development. Use Redis or a database table in production."""
|
|
109
|
+
|
|
110
|
+
def __init__(self):
|
|
111
|
+
self._seen = {}
|
|
112
|
+
|
|
113
|
+
def seen(self, jti):
|
|
114
|
+
now = time.time()
|
|
115
|
+
self._seen = {k: v for k, v in self._seen.items() if v > now}
|
|
116
|
+
return jti in self._seen
|
|
117
|
+
|
|
118
|
+
def remember(self, jti, ttl_seconds):
|
|
119
|
+
self._seen[jti] = time.time() + ttl_seconds
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def verify_id_jag(token, *, trusted_issuers, audience, jti_store, is_known_client_id=None,
|
|
123
|
+
max_auth_age_seconds=3600, clock_skew_seconds=60):
|
|
124
|
+
"""Verify an ID-JAG from a trusted agent provider, per the auth.md agent-services guide.
|
|
125
|
+
|
|
126
|
+
trusted_issuers: {issuer: {"jwks_uri": ...} or {"jwks": {...}}, optional "display_name"}
|
|
127
|
+
(jwks_uri defaults to {iss}/.well-known/jwks.json)
|
|
128
|
+
audience: your authorization server's issuer.
|
|
129
|
+
jti_store: object with seen(jti) and remember(jti, ttl_seconds). Required for replay protection.
|
|
130
|
+
|
|
131
|
+
Raises AgentAuthError with spec error codes. Does not do user matching or first-link step-up;
|
|
132
|
+
never silently bind an ID-JAG to an existing account by email.
|
|
133
|
+
"""
|
|
134
|
+
if not trusted_issuers or not audience or jti_store is None:
|
|
135
|
+
raise ValueError("trusted_issuers, audience and jti_store are required")
|
|
136
|
+
try:
|
|
137
|
+
header = jwt.get_unverified_header(token)
|
|
138
|
+
unverified = jwt.decode(token, options={"verify_signature": False})
|
|
139
|
+
except jwt.PyJWTError as e:
|
|
140
|
+
raise AgentAuthError("invalid_signature", f"malformed token: {e}") from e
|
|
141
|
+
if header.get("typ") != ID_JAG_TYP or header.get("alg") not in ALLOWED_ALGS:
|
|
142
|
+
raise AgentAuthError("invalid_signature", "wrong typ or disallowed algorithm")
|
|
143
|
+
|
|
144
|
+
iss = unverified.get("iss")
|
|
145
|
+
entry = trusted_issuers.get(iss) if isinstance(iss, str) else None
|
|
146
|
+
if not entry:
|
|
147
|
+
raise AgentAuthError("invalid_issuer", "issuer is not on the trust list")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
if "jwks" in entry:
|
|
151
|
+
kid = header.get("kid")
|
|
152
|
+
keys = [k for k in PyJWKSet.from_dict(entry["jwks"]).keys if kid is None or k.key_id == kid]
|
|
153
|
+
if not keys:
|
|
154
|
+
raise AgentAuthError("invalid_signature", "no matching key")
|
|
155
|
+
key = keys[0].key
|
|
156
|
+
else:
|
|
157
|
+
uri = entry.get("jwks_uri") or iss.rstrip("/") + "/.well-known/jwks.json"
|
|
158
|
+
key = PyJWKClient(uri).get_signing_key_from_jwt(token).key
|
|
159
|
+
claims = jwt.decode(token, key, algorithms=ALLOWED_ALGS, audience=audience, issuer=iss,
|
|
160
|
+
leeway=clock_skew_seconds,
|
|
161
|
+
options={"require": ["iss", "sub", "aud", "client_id", "jti", "iat", "exp"]})
|
|
162
|
+
except jwt.ExpiredSignatureError as e:
|
|
163
|
+
raise AgentAuthError("expired", "assertion expired") from e
|
|
164
|
+
except jwt.InvalidAudienceError as e:
|
|
165
|
+
raise AgentAuthError("invalid_audience", "wrong audience") from e
|
|
166
|
+
except jwt.MissingRequiredClaimError as e:
|
|
167
|
+
code = "invalid_client_id" if e.claim == "client_id" else "invalid_signature"
|
|
168
|
+
raise AgentAuthError(code, str(e)) from e
|
|
169
|
+
except jwt.PyJWTError as e:
|
|
170
|
+
raise AgentAuthError("invalid_signature", str(e)) from e
|
|
171
|
+
|
|
172
|
+
if is_known_client_id and not is_known_client_id(claims["client_id"], iss):
|
|
173
|
+
raise AgentAuthError("invalid_client_id", "client_id does not resolve to this provider")
|
|
174
|
+
if claims.get("email_verified") is not True and claims.get("phone_number_verified") is not True:
|
|
175
|
+
raise AgentAuthError("missing_verified_email", "no verified email or phone")
|
|
176
|
+
auth_time = claims.get("auth_time")
|
|
177
|
+
if not isinstance(auth_time, (int, float)):
|
|
178
|
+
raise AgentAuthError("login_required", "auth_time missing", 401)
|
|
179
|
+
if time.time() - auth_time > max_auth_age_seconds:
|
|
180
|
+
raise AgentAuthError("login_required", "auth_time too old", 401)
|
|
181
|
+
|
|
182
|
+
replay_key = f"{iss}|{claims['jti']}"
|
|
183
|
+
if jti_store.seen(replay_key):
|
|
184
|
+
raise AgentAuthError("replay_detected", "jti already used")
|
|
185
|
+
jti_store.remember(replay_key, max(claims["exp"] - claims["iat"], 0) + clock_skew_seconds)
|
|
186
|
+
|
|
187
|
+
return {"issuer": claims["iss"], "subject": claims["sub"], "client_id": claims["client_id"],
|
|
188
|
+
"email": claims.get("email"), "email_verified": claims.get("email_verified") is True,
|
|
189
|
+
"phone_number": claims.get("phone_number"),
|
|
190
|
+
"phone_number_verified": claims.get("phone_number_verified") is True,
|
|
191
|
+
"auth_time": auth_time, "display_name": entry.get("display_name"), "claims": claims}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auth-md
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal helpers for the auth.md agent-registration protocol: discovery metadata, auth.md generation, and ID-JAG verification.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: auth.md,auth-md,agent-auth,ai-agents,id-jag,oauth,agent-registration
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyjwt[crypto]>=2.6
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# auth-md
|
|
14
|
+
|
|
15
|
+
_Community implementation, not affiliated with WorkOS._
|
|
16
|
+
|
|
17
|
+
Minimal helpers for the [auth.md](https://github.com/workos/auth.md) agent-registration protocol.
|
|
18
|
+
|
|
19
|
+
> Early release (0.1.0). Tracks the workos/auth.md agent-services guide as of October 2026. The spec is still evolving.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install auth-md
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Includes `protected_resource_metadata`, `authorization_server_metadata`, `www_authenticate`, `generate_auth_md`, and `verify_id_jag` (trust list, audience, `typ`, asymmetric algorithms only, `client_id`, verified email or phone, `auth_time` freshness, `jti` replay protection).
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from auth_md import verify_id_jag, MemoryJtiStore
|
|
29
|
+
|
|
30
|
+
identity = verify_id_jag(
|
|
31
|
+
assertion,
|
|
32
|
+
audience="https://auth.yourapp.com",
|
|
33
|
+
trusted_issuers={"https://provider.example": {"display_name": "Provider"}},
|
|
34
|
+
jti_store=MemoryJtiStore(), # use Redis or a DB table in production
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Never bind a verified ID-JAG to an existing account by email alone; the spec requires a first-link step-up.
|
|
39
|
+
|
|
40
|
+
MIT licensed.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyjwt[crypto]>=2.6
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auth_md
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "auth-md"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Minimal helpers for the auth.md agent-registration protocol: discovery metadata, auth.md generation, and ID-JAG verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["pyjwt[crypto]>=2.6"]
|
|
13
|
+
keywords = ["auth.md", "auth-md", "agent-auth", "ai-agents", "id-jag", "oauth", "agent-registration"]
|
auth_md-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import base64, json, time, uuid
|
|
2
|
+
import jwt, pytest
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
4
|
+
from jwt.algorithms import ECAlgorithm
|
|
5
|
+
from auth_md import (verify_id_jag, MemoryJtiStore, AgentAuthError, generate_auth_md,
|
|
6
|
+
protected_resource_metadata, authorization_server_metadata)
|
|
7
|
+
|
|
8
|
+
ISS, AUD = "https://provider.example", "https://auth.service.example"
|
|
9
|
+
priv = ec.generate_private_key(ec.SECP256R1())
|
|
10
|
+
jwk = json.loads(ECAlgorithm.to_jwk(priv.public_key())); jwk.update(kid="k1", alg="ES256")
|
|
11
|
+
TRUST = {ISS: {"jwks": {"keys": [jwk]}}}
|
|
12
|
+
|
|
13
|
+
def good(**over):
|
|
14
|
+
c = {"iss": ISS, "sub": "u1", "aud": AUD, "client_id": "agent-app", "jti": str(uuid.uuid4()),
|
|
15
|
+
"email": "a@b.c", "email_verified": True, "auth_time": int(time.time()) - 60}
|
|
16
|
+
c.update(over)
|
|
17
|
+
return {k: v for k, v in c.items() if v is not None}
|
|
18
|
+
|
|
19
|
+
def sign(claims, typ="oauth-id-jag+jwt"):
|
|
20
|
+
now = int(time.time())
|
|
21
|
+
return jwt.encode({"iat": now, "exp": now + 300, **claims}, priv, algorithm="ES256", headers={"kid": "k1", "typ": typ})
|
|
22
|
+
|
|
23
|
+
def opts():
|
|
24
|
+
return dict(trusted_issuers=TRUST, audience=AUD, jti_store=MemoryJtiStore())
|
|
25
|
+
|
|
26
|
+
def test_valid():
|
|
27
|
+
r = verify_id_jag(sign(good()), **opts())
|
|
28
|
+
assert r["subject"] == "u1" and r["client_id"] == "agent-app"
|
|
29
|
+
|
|
30
|
+
def test_phone_instead_of_email():
|
|
31
|
+
assert verify_id_jag(sign(good(email_verified=False, phone_number_verified=True)), **opts())["phone_number_verified"]
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("over,code", [
|
|
34
|
+
(dict(iss="https://evil.example"), "invalid_issuer"),
|
|
35
|
+
(dict(aud="https://other.example"), "invalid_audience"),
|
|
36
|
+
(dict(email_verified=False), "missing_verified_email"),
|
|
37
|
+
(dict(auth_time=None), "login_required"),
|
|
38
|
+
(dict(auth_time=int(time.time()) - 7200), "login_required"),
|
|
39
|
+
(dict(client_id=None), "invalid_client_id"),
|
|
40
|
+
])
|
|
41
|
+
def test_rejects(over, code):
|
|
42
|
+
with pytest.raises(AgentAuthError) as e:
|
|
43
|
+
verify_id_jag(sign(good(**over)), **opts())
|
|
44
|
+
assert e.value.code == code
|
|
45
|
+
|
|
46
|
+
def test_wrong_typ():
|
|
47
|
+
with pytest.raises(AgentAuthError):
|
|
48
|
+
verify_id_jag(sign(good(), typ="JWT"), **opts())
|
|
49
|
+
|
|
50
|
+
def test_replay():
|
|
51
|
+
o, t = opts(), sign(good())
|
|
52
|
+
verify_id_jag(t, **o)
|
|
53
|
+
with pytest.raises(AgentAuthError) as e:
|
|
54
|
+
verify_id_jag(t, **o)
|
|
55
|
+
assert e.value.code == "replay_detected"
|
|
56
|
+
|
|
57
|
+
def test_alg_none():
|
|
58
|
+
b = lambda o: base64.urlsafe_b64encode(json.dumps(o).encode()).rstrip(b"=").decode()
|
|
59
|
+
now = int(time.time())
|
|
60
|
+
t = f'{b({"alg": "none", "typ": "oauth-id-jag+jwt"})}.{b({**good(), "iat": now, "exp": now + 300})}.'
|
|
61
|
+
with pytest.raises(AgentAuthError):
|
|
62
|
+
verify_id_jag(t, **opts())
|
|
63
|
+
|
|
64
|
+
def test_metadata_and_auth_md():
|
|
65
|
+
prm = protected_resource_metadata(resource="https://api.acme.dev/", authorization_servers=["https://auth.acme.dev/"])
|
|
66
|
+
assert "agent_auth" not in prm
|
|
67
|
+
asm = authorization_server_metadata(issuer="https://auth.acme.dev", token_endpoint="https://auth.acme.dev/oauth2/token",
|
|
68
|
+
auth_md_url="https://acme.dev/auth.md", identity_endpoint="https://auth.acme.dev/agent/identity")
|
|
69
|
+
assert asm["agent_auth"]["identity_types_supported"] == ["identity_assertion", "service_auth"]
|
|
70
|
+
md = generate_auth_md(service_name="Acme", resource_metadata_url="https://api.acme.dev/.well-known/oauth-protected-resource",
|
|
71
|
+
identity_endpoint="https://auth.acme.dev/agent/identity", token_endpoint="https://auth.acme.dev/oauth2/token")
|
|
72
|
+
assert "service_auth" in md and "user_claimed" not in md
|
|
73
|
+
|
|
74
|
+
def test_future_iat():
|
|
75
|
+
n = int(time.time())
|
|
76
|
+
t = jwt.encode({**good(), "iat": n + 3600, "exp": n + 7200}, priv, algorithm="ES256", headers={"kid": "k1", "typ": "oauth-id-jag+jwt"})
|
|
77
|
+
with pytest.raises(AgentAuthError):
|
|
78
|
+
verify_id_jag(t, **opts())
|