xyberos-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xyberos_auth-0.1.0/PKG-INFO +79 -0
- xyberos_auth-0.1.0/README.md +65 -0
- xyberos_auth-0.1.0/pyproject.toml +27 -0
- xyberos_auth-0.1.0/setup.cfg +4 -0
- xyberos_auth-0.1.0/tests/test_jwt.py +65 -0
- xyberos_auth-0.1.0/tests/test_oauth2.py +62 -0
- xyberos_auth-0.1.0/tests/test_oidc.py +69 -0
- xyberos_auth-0.1.0/tests/test_plugin.py +36 -0
- xyberos_auth-0.1.0/xyberos_auth/__init__.py +17 -0
- xyberos_auth-0.1.0/xyberos_auth/errors.py +7 -0
- xyberos_auth-0.1.0/xyberos_auth/http.py +64 -0
- xyberos_auth-0.1.0/xyberos_auth/jwt.py +128 -0
- xyberos_auth-0.1.0/xyberos_auth/oauth2.py +96 -0
- xyberos_auth-0.1.0/xyberos_auth/oidc.py +83 -0
- xyberos_auth-0.1.0/xyberos_auth/plugin.py +91 -0
- xyberos_auth-0.1.0/xyberos_auth/presets.py +30 -0
- xyberos_auth-0.1.0/xyberos_auth.egg-info/PKG-INFO +79 -0
- xyberos_auth-0.1.0/xyberos_auth.egg-info/SOURCES.txt +20 -0
- xyberos_auth-0.1.0/xyberos_auth.egg-info/dependency_links.txt +1 -0
- xyberos_auth-0.1.0/xyberos_auth.egg-info/entry_points.txt +2 -0
- xyberos_auth-0.1.0/xyberos_auth.egg-info/requires.txt +7 -0
- xyberos_auth-0.1.0/xyberos_auth.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Auth plugin (RFC-0019, M9): OAuth2, OIDC, JWT with Auth0/Okta/Entra presets
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,auth,oauth2,oidc,jwt,sso
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: rsa
|
|
11
|
+
Requires-Dist: cryptography; extra == "rsa"
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
|
14
|
+
|
|
15
|
+
# xyberos-auth
|
|
16
|
+
|
|
17
|
+
**Auth plugin — RFC-0019, M9.** OAuth 2.0, OpenID Connect, and JWT, with
|
|
18
|
+
Auth0 / Okta / Microsoft Entra presets. All stdlib (`urllib` + `hmac`); RS256
|
|
19
|
+
uses lazy `cryptography`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e ./auth
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## JWT
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from xyberos_auth import JwtCodec
|
|
31
|
+
|
|
32
|
+
codec = JwtCodec("a-shared-secret")
|
|
33
|
+
token = codec.encode({"sub": "user-1"}, ttl=3600)
|
|
34
|
+
codec.decode(token, verify=True) # raises AuthError on tamper/expiry
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or through the plugin (HS256 via `AUTH_JWT_SECRET`):
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from xyberos import create_app
|
|
41
|
+
from xyberos_auth import AuthPlugin
|
|
42
|
+
|
|
43
|
+
app = create_app()
|
|
44
|
+
app.load_plugin(AuthPlugin(secret="dev-secret"))
|
|
45
|
+
app.tools.execute("jwt_sign", None, payload={"sub": "user-1"}, ttl=600)
|
|
46
|
+
app.tools.execute("jwt_verify", None, token=token)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## OAuth2 / OIDC
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from xyberos_auth import OAuth2Client, build_oidc
|
|
53
|
+
|
|
54
|
+
oauth2 = OAuth2Client("cid", "csecret",
|
|
55
|
+
authorize_url="https://idp/authorize",
|
|
56
|
+
token_url="https://idp/token", scope="openid profile")
|
|
57
|
+
url = oauth2.authorization_url(state="abc")
|
|
58
|
+
tokens = oauth2.exchange_code("code")
|
|
59
|
+
|
|
60
|
+
oidc = build_oidc("auth0", client_id="cid", client_secret="csecret", tenant="myco")
|
|
61
|
+
user = oidc.userinfo(tokens["access_token"])
|
|
62
|
+
claims = oidc.verify_id_token(tokens.get("id_token", ""))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Presets: `auth0` (`{tenant}`), `okta` (`{org}`), `entra` (`{tenant}`).
|
|
66
|
+
|
|
67
|
+
## Tests
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install pytest
|
|
71
|
+
pytest tests/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Transport-injectable (OAuth2/OIDC) + real HMAC/RSA JWT tests (skip RS256 when
|
|
75
|
+
`cryptography` is absent).
|
|
76
|
+
|
|
77
|
+
## Ship location
|
|
78
|
+
|
|
79
|
+
Plugin (`xyberos.plugins` entry point) — enterprise auth (M9).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# xyberos-auth
|
|
2
|
+
|
|
3
|
+
**Auth plugin — RFC-0019, M9.** OAuth 2.0, OpenID Connect, and JWT, with
|
|
4
|
+
Auth0 / Okta / Microsoft Entra presets. All stdlib (`urllib` + `hmac`); RS256
|
|
5
|
+
uses lazy `cryptography`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e ./auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## JWT
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from xyberos_auth import JwtCodec
|
|
17
|
+
|
|
18
|
+
codec = JwtCodec("a-shared-secret")
|
|
19
|
+
token = codec.encode({"sub": "user-1"}, ttl=3600)
|
|
20
|
+
codec.decode(token, verify=True) # raises AuthError on tamper/expiry
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or through the plugin (HS256 via `AUTH_JWT_SECRET`):
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from xyberos import create_app
|
|
27
|
+
from xyberos_auth import AuthPlugin
|
|
28
|
+
|
|
29
|
+
app = create_app()
|
|
30
|
+
app.load_plugin(AuthPlugin(secret="dev-secret"))
|
|
31
|
+
app.tools.execute("jwt_sign", None, payload={"sub": "user-1"}, ttl=600)
|
|
32
|
+
app.tools.execute("jwt_verify", None, token=token)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## OAuth2 / OIDC
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from xyberos_auth import OAuth2Client, build_oidc
|
|
39
|
+
|
|
40
|
+
oauth2 = OAuth2Client("cid", "csecret",
|
|
41
|
+
authorize_url="https://idp/authorize",
|
|
42
|
+
token_url="https://idp/token", scope="openid profile")
|
|
43
|
+
url = oauth2.authorization_url(state="abc")
|
|
44
|
+
tokens = oauth2.exchange_code("code")
|
|
45
|
+
|
|
46
|
+
oidc = build_oidc("auth0", client_id="cid", client_secret="csecret", tenant="myco")
|
|
47
|
+
user = oidc.userinfo(tokens["access_token"])
|
|
48
|
+
claims = oidc.verify_id_token(tokens.get("id_token", ""))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Presets: `auth0` (`{tenant}`), `okta` (`{org}`), `entra` (`{tenant}`).
|
|
52
|
+
|
|
53
|
+
## Tests
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install pytest
|
|
57
|
+
pytest tests/
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Transport-injectable (OAuth2/OIDC) + real HMAC/RSA JWT tests (skip RS256 when
|
|
61
|
+
`cryptography` is absent).
|
|
62
|
+
|
|
63
|
+
## Ship location
|
|
64
|
+
|
|
65
|
+
Plugin (`xyberos.plugins` entry point) — enterprise auth (M9).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xyberos-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Auth plugin (RFC-0019, M9): OAuth2, OIDC, JWT with Auth0/Okta/Entra presets"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
dependencies = ["xyberos>=1.0"]
|
|
13
|
+
keywords = ["xyberos", "plugin", "auth", "oauth2", "oidc", "jwt", "sso"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
rsa = ["cryptography"]
|
|
17
|
+
test = ["pytest"]
|
|
18
|
+
|
|
19
|
+
[project.entry-points."xyberos.plugins"]
|
|
20
|
+
auth = "xyberos_auth.plugin:plugin"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
packages = ["xyberos_auth"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for the JWT codec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from xyberos_auth import JwtCodec
|
|
10
|
+
from xyberos_auth.errors import AuthError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_hs256_roundtrip():
|
|
14
|
+
codec = JwtCodec("secret")
|
|
15
|
+
token = codec.encode({"sub": "user-1", "role": "admin"})
|
|
16
|
+
claims = codec.decode(token)
|
|
17
|
+
assert claims["sub"] == "user-1"
|
|
18
|
+
assert claims["role"] == "admin"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_hs256_ttl():
|
|
22
|
+
codec = JwtCodec("secret")
|
|
23
|
+
token = codec.encode({"sub": "u"}, ttl=3600)
|
|
24
|
+
claims = codec.decode(token)
|
|
25
|
+
assert claims["exp"] - claims["iat"] == 3600
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_hs256_tamper_detected():
|
|
29
|
+
codec = JwtCodec("secret")
|
|
30
|
+
token = codec.encode({"sub": "u"})
|
|
31
|
+
tampered = token[:-2] + ("AA" if not token.endswith("AA") else "BB")
|
|
32
|
+
with pytest.raises(AuthError, match="signature"):
|
|
33
|
+
codec.decode(tampered)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_hs256_wrong_secret():
|
|
37
|
+
token = JwtCodec("secret-a").encode({"sub": "u"})
|
|
38
|
+
with pytest.raises(AuthError, match="signature"):
|
|
39
|
+
JwtCodec("secret-b").decode(token)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_requires_secret():
|
|
43
|
+
with pytest.raises(AuthError, match="secret"):
|
|
44
|
+
JwtCodec(algorithm="HS256")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.skipif(importlib.util.find_spec("cryptography") is None, reason="cryptography not installed")
|
|
48
|
+
def test_rs256_roundtrip():
|
|
49
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
50
|
+
from cryptography.hazmat.primitives import serialization
|
|
51
|
+
|
|
52
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
53
|
+
private_pem = private_key.private_bytes(
|
|
54
|
+
serialization.Encoding.PEM,
|
|
55
|
+
serialization.PrivateFormat.PKCS8,
|
|
56
|
+
serialization.NoEncryption(),
|
|
57
|
+
).decode("utf-8")
|
|
58
|
+
public_pem = (
|
|
59
|
+
private_key.public_key()
|
|
60
|
+
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
61
|
+
.decode("utf-8")
|
|
62
|
+
)
|
|
63
|
+
codec = JwtCodec(algorithm="RS256", private_key=private_pem, public_key=public_pem)
|
|
64
|
+
token = codec.encode({"sub": "u"})
|
|
65
|
+
assert codec.decode(token)["sub"] == "u"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Tests for the OAuth2 client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from xyberos_auth import OAuth2Client
|
|
8
|
+
from xyberos_auth.errors import AuthError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _token_response():
|
|
12
|
+
return {"access_token": "at", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rt"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_authorization_url():
|
|
16
|
+
client = OAuth2Client("cid", "csecret", authorize_url="https://idp/authorize", token_url="https://idp/token", scope="openid")
|
|
17
|
+
url = client.authorization_url("state-1")
|
|
18
|
+
assert url.startswith("https://idp/authorize?")
|
|
19
|
+
assert "client_id=cid" in url
|
|
20
|
+
assert "state=state-1" in url
|
|
21
|
+
assert "response_type=code" in url
|
|
22
|
+
assert "scope=openid" in url
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_exchange_code():
|
|
26
|
+
captured = {}
|
|
27
|
+
|
|
28
|
+
def request(method, url, **kwargs):
|
|
29
|
+
captured.update(kwargs)
|
|
30
|
+
return 200, _token_response()
|
|
31
|
+
|
|
32
|
+
client = OAuth2Client("cid", "csecret", token_url="https://idp/token", request=request)
|
|
33
|
+
result = client.exchange_code("the-code", redirect_uri="https://app/cb")
|
|
34
|
+
assert result["access_token"] == "at"
|
|
35
|
+
assert captured["form"]["grant_type"] == "authorization_code"
|
|
36
|
+
assert captured["form"]["code"] == "the-code"
|
|
37
|
+
assert captured["form"]["client_id"] == "cid"
|
|
38
|
+
assert captured["form"]["client_secret"] == "csecret"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_refresh_and_client_credentials():
|
|
42
|
+
captured = []
|
|
43
|
+
|
|
44
|
+
def request(method, url, **kwargs):
|
|
45
|
+
captured.append(kwargs["form"])
|
|
46
|
+
return 200, _token_response()
|
|
47
|
+
|
|
48
|
+
client = OAuth2Client("cid", "csecret", token_url="https://idp/token", request=request)
|
|
49
|
+
client.refresh("refresh-tok")
|
|
50
|
+
client.client_credentials(scope="api")
|
|
51
|
+
assert captured[0]["grant_type"] == "refresh_token"
|
|
52
|
+
assert captured[1]["grant_type"] == "client_credentials"
|
|
53
|
+
assert captured[1]["scope"] == "api"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_token_error_raises():
|
|
57
|
+
def request(method, url, **kwargs):
|
|
58
|
+
return 400, {"error": "invalid_grant"}
|
|
59
|
+
|
|
60
|
+
client = OAuth2Client("cid", "csecret", token_url="https://idp/token", request=request)
|
|
61
|
+
with pytest.raises(AuthError, match="400"):
|
|
62
|
+
client.exchange_code("bad")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for the OIDC client and presets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from xyberos_auth import OidcClient, build_oidc, get_preset
|
|
8
|
+
from xyberos_auth.errors import AuthError
|
|
9
|
+
from xyberos_auth.jwt import JwtCodec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _discovery_body():
|
|
13
|
+
return {
|
|
14
|
+
"issuer": "https://idp.example.com",
|
|
15
|
+
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
|
16
|
+
"authorization_endpoint": "https://idp.example.com/authorize",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_discovery_and_userinfo():
|
|
21
|
+
def request(method, url, **kwargs):
|
|
22
|
+
if url.endswith("/.well-known/openid-configuration"):
|
|
23
|
+
return 200, _discovery_body()
|
|
24
|
+
if url.endswith("/userinfo"):
|
|
25
|
+
return 200, {"sub": "user-1", "email": "a@b.com"}
|
|
26
|
+
return 404, {}
|
|
27
|
+
|
|
28
|
+
client = OidcClient("https://idp.example.com", request=request)
|
|
29
|
+
metadata = client.discovery()
|
|
30
|
+
assert metadata["userinfo_endpoint"] == "https://idp.example.com/userinfo"
|
|
31
|
+
user = client.userinfo("at")
|
|
32
|
+
assert user["email"] == "a@b.com"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_verify_id_token_hs256():
|
|
36
|
+
codec = JwtCodec("client-secret")
|
|
37
|
+
token = codec.encode({"sub": "u", "aud": "app-id"})
|
|
38
|
+
client = OidcClient("https://idp", client_id="app-id", client_secret="client-secret")
|
|
39
|
+
claims = client.verify_id_token(token)
|
|
40
|
+
assert claims["sub"] == "u"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_verify_id_token_audience_mismatch():
|
|
44
|
+
codec = JwtCodec("client-secret")
|
|
45
|
+
token = codec.encode({"sub": "u", "aud": "other-app"})
|
|
46
|
+
client = OidcClient("https://idp", client_id="app-id", client_secret="client-secret")
|
|
47
|
+
with pytest.raises(AuthError, match="audience"):
|
|
48
|
+
client.verify_id_token(token)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_presets():
|
|
52
|
+
assert get_preset("auth0", tenant="myco") == "https://myco.auth0.com"
|
|
53
|
+
assert get_preset("okta", org="myorg") == "https://myorg.okta.com/oauth2/default"
|
|
54
|
+
assert get_preset("entra", tenant="mytenant") == "https://login.microsoftonline.com/mytenant/v2.0"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_preset_requires_tenant():
|
|
58
|
+
with pytest.raises(ValueError, match="tenant"):
|
|
59
|
+
get_preset("auth0")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_unknown_preset():
|
|
63
|
+
with pytest.raises(ValueError, match="unknown OIDC preset"):
|
|
64
|
+
get_preset("bogus")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_build_oidc():
|
|
68
|
+
client = build_oidc("auth0", client_id="cid", client_secret="s", tenant="myco")
|
|
69
|
+
assert isinstance(client, OidcClient)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tests for loading the auth plugin into a Xyberos app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from xyberos import create_app
|
|
6
|
+
|
|
7
|
+
from xyberos_auth import AuthPlugin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_plugin_conforms_to_contract():
|
|
11
|
+
plugin = AuthPlugin()
|
|
12
|
+
assert plugin.name == "auth"
|
|
13
|
+
assert callable(plugin.register) and callable(plugin.unregister)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_unconfigured_register_is_safe():
|
|
17
|
+
app = create_app()
|
|
18
|
+
app.load_plugin(AuthPlugin()) # no secret -> no-op
|
|
19
|
+
assert app.plugins.names == ("auth",)
|
|
20
|
+
app.unload_plugin("auth")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_plugin_registers_jwt_tools():
|
|
24
|
+
app = create_app()
|
|
25
|
+
app.load_plugin(AuthPlugin(secret="dev-secret"))
|
|
26
|
+
assert "jwt_sign" in app.tools.names
|
|
27
|
+
assert "jwt_verify" in app.tools.names
|
|
28
|
+
|
|
29
|
+
token = app.tools.execute("jwt_sign", None, payload={"sub": "user-1"}, ttl=600)
|
|
30
|
+
assert isinstance(token, str) and token.count(".") == 2
|
|
31
|
+
|
|
32
|
+
claims = app.tools.execute("jwt_verify", None, token=token)
|
|
33
|
+
assert claims["sub"] == "user-1"
|
|
34
|
+
|
|
35
|
+
app.unload_plugin("auth")
|
|
36
|
+
assert "jwt_sign" not in app.tools.names
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Auth plugin (RFC-0019, M9): OAuth2, OIDC, JWT with Auth0/Okta/Entra presets."""
|
|
2
|
+
|
|
3
|
+
from .jwt import JwtCodec
|
|
4
|
+
from .oauth2 import OAuth2Client
|
|
5
|
+
from .oidc import OidcClient
|
|
6
|
+
from .plugin import AuthPlugin
|
|
7
|
+
from .presets import OIDC_PRESETS, build_oidc, get_preset
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AuthPlugin",
|
|
11
|
+
"JwtCodec",
|
|
12
|
+
"OAuth2Client",
|
|
13
|
+
"OIDC_PRESETS",
|
|
14
|
+
"OidcClient",
|
|
15
|
+
"build_oidc",
|
|
16
|
+
"get_preset",
|
|
17
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""A tiny stdlib HTTP helper (no third-party deps).
|
|
2
|
+
|
|
3
|
+
``default_request`` performs one HTTP request with ``urllib`` and returns
|
|
4
|
+
``(status, body)`` where ``body`` is parsed JSON when the response is JSON,
|
|
5
|
+
otherwise raw text. Injectable so tests run without a network.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
#: (method, url, *, json_body, raw_body, headers, query, timeout) -> (status, body)
|
|
17
|
+
RequestTransport = Callable[..., tuple[int, Any]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_request(
|
|
21
|
+
method: str,
|
|
22
|
+
url: str,
|
|
23
|
+
*,
|
|
24
|
+
json_body: Any = None,
|
|
25
|
+
raw_body: bytes | None = None,
|
|
26
|
+
headers: dict[str, str] | None = None,
|
|
27
|
+
query: dict[str, Any] | None = None,
|
|
28
|
+
form: dict[str, str] | None = None,
|
|
29
|
+
timeout: float = 30.0,
|
|
30
|
+
) -> tuple[int, Any]:
|
|
31
|
+
"""Send one request and return ``(status, parsed_json_or_text)``."""
|
|
32
|
+
final_url = url
|
|
33
|
+
if query:
|
|
34
|
+
separator = "&" if "?" in url else "?"
|
|
35
|
+
final_url = url + separator + urllib.parse.urlencode(query)
|
|
36
|
+
|
|
37
|
+
data: bytes | None = None
|
|
38
|
+
request_headers = dict(headers or {})
|
|
39
|
+
if form is not None:
|
|
40
|
+
data = urllib.parse.urlencode(form).encode("utf-8")
|
|
41
|
+
request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
|
42
|
+
elif raw_body is not None:
|
|
43
|
+
data = raw_body
|
|
44
|
+
elif json_body is not None:
|
|
45
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
46
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
47
|
+
|
|
48
|
+
request = urllib.request.Request(
|
|
49
|
+
final_url, data=data, headers=request_headers, method=method
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
53
|
+
raw = response.read()
|
|
54
|
+
content_type = response.headers.get("Content-Type", "")
|
|
55
|
+
except urllib.error.HTTPError as exc:
|
|
56
|
+
return exc.code, exc.read().decode("utf-8", errors="replace")
|
|
57
|
+
|
|
58
|
+
text = raw.decode("utf-8", errors="replace")
|
|
59
|
+
if "application/json" in content_type or text.lstrip().startswith("{"):
|
|
60
|
+
try:
|
|
61
|
+
return 200, json.loads(text)
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
return 200, text
|
|
64
|
+
return 200, text
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""JWT encode/decode with HS256 (stdlib) and RS256 (lazy ``cryptography``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import importlib
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from xyberos.exceptions.provider import ProviderError
|
|
14
|
+
|
|
15
|
+
from .errors import AuthError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _b64url(data: bytes) -> str:
|
|
19
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _b64url_decode(text: str) -> bytes:
|
|
23
|
+
padding = "=" * (-len(text) % 4)
|
|
24
|
+
return base64.urlsafe_b64decode(text + padding)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JwtCodec:
|
|
28
|
+
"""Sign and verify JSON Web Tokens (HS256 / RS256)."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
secret: str | None = None,
|
|
33
|
+
*,
|
|
34
|
+
algorithm: str = "HS256",
|
|
35
|
+
private_key: str | None = None,
|
|
36
|
+
public_key: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._secret = secret
|
|
39
|
+
self._algorithm = algorithm.upper()
|
|
40
|
+
self._private_key = private_key
|
|
41
|
+
self._public_key = public_key
|
|
42
|
+
if self._algorithm not in ("HS256", "RS256"):
|
|
43
|
+
raise AuthError(f"unsupported JWT algorithm: {self._algorithm}")
|
|
44
|
+
if self._algorithm == "HS256" and not secret:
|
|
45
|
+
raise AuthError("HS256 requires a 'secret'")
|
|
46
|
+
if self._algorithm == "RS256" and not (private_key and public_key):
|
|
47
|
+
raise AuthError("RS256 requires both 'private_key' and 'public_key'")
|
|
48
|
+
|
|
49
|
+
# -- public API ---------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def encode(self, payload: dict[str, Any], *, ttl: int | None = None) -> str:
|
|
52
|
+
"""Return a signed JWT for ``payload`` (optionally with an expiry)."""
|
|
53
|
+
header = {"alg": self._algorithm, "typ": "JWT"}
|
|
54
|
+
claims = dict(payload)
|
|
55
|
+
if ttl is not None:
|
|
56
|
+
now = int(time.time())
|
|
57
|
+
claims.setdefault("iat", now)
|
|
58
|
+
claims.setdefault("exp", now + ttl)
|
|
59
|
+
signing_input = (
|
|
60
|
+
_b64url(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
|
61
|
+
+ "."
|
|
62
|
+
+ _b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
|
|
63
|
+
)
|
|
64
|
+
signature = self._sign(header["alg"], signing_input.encode("utf-8"))
|
|
65
|
+
return f"{signing_input}.{_b64url(signature)}"
|
|
66
|
+
|
|
67
|
+
def decode(self, token: str, *, verify: bool = True) -> dict[str, Any]:
|
|
68
|
+
"""Decode a JWT; raise :class:`AuthError` on tampering or expiry."""
|
|
69
|
+
parts = token.split(".")
|
|
70
|
+
if len(parts) != 3:
|
|
71
|
+
raise AuthError("invalid JWT: expected three dot-separated parts")
|
|
72
|
+
try:
|
|
73
|
+
header = json.loads(_b64url_decode(parts[0]))
|
|
74
|
+
claims = json.loads(_b64url_decode(parts[1]))
|
|
75
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
76
|
+
raise AuthError("invalid JWT: malformed header/claims") from exc
|
|
77
|
+
if verify:
|
|
78
|
+
self._verify(header.get("alg"), f"{parts[0]}.{parts[1]}".encode("utf-8"), parts[2])
|
|
79
|
+
expires = claims.get("exp")
|
|
80
|
+
if isinstance(expires, (int, float)) and time.time() >= expires:
|
|
81
|
+
raise AuthError("JWT has expired")
|
|
82
|
+
return claims
|
|
83
|
+
|
|
84
|
+
# -- internals ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _sign(self, alg: str, data: bytes) -> bytes:
|
|
87
|
+
if alg == "HS256":
|
|
88
|
+
return hmac.new(self._secret.encode("utf-8"), data, hashlib.sha256).digest()
|
|
89
|
+
if alg == "RS256":
|
|
90
|
+
return self._rsa_sign(data)
|
|
91
|
+
raise AuthError(f"unsupported JWT algorithm: {alg}")
|
|
92
|
+
|
|
93
|
+
def _verify(self, alg: str, data: bytes, signature: str) -> None:
|
|
94
|
+
if alg == "HS256":
|
|
95
|
+
expected = self._sign("HS256", data)
|
|
96
|
+
if not hmac.compare_digest(expected, _b64url_decode(signature)):
|
|
97
|
+
raise AuthError("JWT signature verification failed")
|
|
98
|
+
return
|
|
99
|
+
if alg == "RS256":
|
|
100
|
+
self._rsa_verify(data, _b64url_decode(signature))
|
|
101
|
+
return
|
|
102
|
+
raise AuthError(f"unsupported JWT algorithm: {alg}")
|
|
103
|
+
|
|
104
|
+
def _rsa_sign(self, data: bytes) -> bytes:
|
|
105
|
+
primitives, asymmetric, padding, serialization, hashes = self._rsa_modules()
|
|
106
|
+
key = serialization.load_pem_private_key(self._private_key.encode("utf-8"), password=None)
|
|
107
|
+
return key.sign(data, padding.PKCS1v15(), hashes.SHA256())
|
|
108
|
+
|
|
109
|
+
def _rsa_verify(self, data: bytes, signature: bytes) -> None:
|
|
110
|
+
primitives, asymmetric, padding, serialization, hashes = self._rsa_modules()
|
|
111
|
+
key = serialization.load_pem_public_key(self._public_key.encode("utf-8"))
|
|
112
|
+
try:
|
|
113
|
+
key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
raise AuthError("JWT signature verification failed") from exc
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _rsa_modules() -> tuple[Any, Any, Any, Any, Any]:
|
|
119
|
+
"""Lazily import the ``cryptography`` modules for RS256 (optional dep)."""
|
|
120
|
+
try:
|
|
121
|
+
primitives = importlib.import_module("cryptography.hazmat.primitives")
|
|
122
|
+
asymmetric = importlib.import_module("cryptography.hazmat.primitives.asymmetric")
|
|
123
|
+
except ImportError as exc:
|
|
124
|
+
raise ProviderError(
|
|
125
|
+
"RS256 requires 'cryptography'; install with "
|
|
126
|
+
"'pip install xyberos-auth[rsa]'"
|
|
127
|
+
) from exc
|
|
128
|
+
return primitives, asymmetric, asymmetric.padding, primitives.serialization, primitives.hashes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""OAuth 2.0 client (authorization code, client credentials, refresh)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import AuthError
|
|
9
|
+
from .http import RequestTransport, default_request
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _raise_for_status(status: int, body: Any) -> None:
|
|
13
|
+
if 200 <= status < 300:
|
|
14
|
+
return
|
|
15
|
+
message = body if isinstance(body, str) else str(body)
|
|
16
|
+
raise AuthError(f"OAuth endpoint returned HTTP {status}: {message[:200]}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OAuth2Client:
|
|
20
|
+
"""A minimal OAuth 2.0 client with injectable transport."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
client_id: str,
|
|
25
|
+
client_secret: str,
|
|
26
|
+
*,
|
|
27
|
+
authorize_url: str = "",
|
|
28
|
+
token_url: str,
|
|
29
|
+
redirect_uri: str | None = None,
|
|
30
|
+
scope: str = "",
|
|
31
|
+
request: RequestTransport | None = None,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._client_id = client_id
|
|
35
|
+
self._client_secret = client_secret
|
|
36
|
+
self._authorize_url = authorize_url
|
|
37
|
+
self._token_url = token_url
|
|
38
|
+
self._redirect_uri = redirect_uri
|
|
39
|
+
self._scope = scope
|
|
40
|
+
self._request = request or default_request
|
|
41
|
+
self._timeout = timeout
|
|
42
|
+
|
|
43
|
+
def authorization_url(self, state: str, *, redirect_uri: str | None = None) -> str:
|
|
44
|
+
"""Build the authorization-code consent URL."""
|
|
45
|
+
if not self._authorize_url:
|
|
46
|
+
raise AuthError("authorize_url is not configured")
|
|
47
|
+
params = {
|
|
48
|
+
"response_type": "code",
|
|
49
|
+
"client_id": self._client_id,
|
|
50
|
+
"state": state,
|
|
51
|
+
"redirect_uri": redirect_uri or self._redirect_uri or "",
|
|
52
|
+
"scope": self._scope,
|
|
53
|
+
}
|
|
54
|
+
separator = "&" if "?" in self._authorize_url else "?"
|
|
55
|
+
return self._authorize_url + separator + urllib.parse.urlencode(
|
|
56
|
+
{key: value for key, value in params.items() if value}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def exchange_code(self, code: str, *, redirect_uri: str | None = None) -> dict[str, Any]:
|
|
60
|
+
"""Exchange an authorization code for tokens."""
|
|
61
|
+
return self._token_request(
|
|
62
|
+
{
|
|
63
|
+
"grant_type": "authorization_code",
|
|
64
|
+
"code": code,
|
|
65
|
+
"redirect_uri": redirect_uri or self._redirect_uri or "",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def refresh(self, refresh_token: str) -> dict[str, Any]:
|
|
70
|
+
"""Refresh an access token."""
|
|
71
|
+
return self._token_request(
|
|
72
|
+
{"grant_type": "refresh_token", "refresh_token": refresh_token}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def client_credentials(self, scope: str | None = None) -> dict[str, Any]:
|
|
76
|
+
"""Request a token with the client_credentials grant."""
|
|
77
|
+
form = {"grant_type": "client_credentials"}
|
|
78
|
+
if scope:
|
|
79
|
+
form["scope"] = scope
|
|
80
|
+
return self._token_request(form)
|
|
81
|
+
|
|
82
|
+
# -- internals ----------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _token_request(self, form: dict[str, str]) -> dict[str, Any]:
|
|
85
|
+
form.setdefault("client_id", self._client_id)
|
|
86
|
+
form.setdefault("client_secret", self._client_secret)
|
|
87
|
+
status, body = self._request(
|
|
88
|
+
"POST",
|
|
89
|
+
self._token_url,
|
|
90
|
+
form=form,
|
|
91
|
+
timeout=self._timeout,
|
|
92
|
+
)
|
|
93
|
+
_raise_for_status(status, body)
|
|
94
|
+
if not isinstance(body, dict) or "access_token" not in body:
|
|
95
|
+
raise AuthError(f"token endpoint returned an unexpected response: {body}")
|
|
96
|
+
return body
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""OpenID Connect client (discovery, userinfo, id_token verification)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import AuthError
|
|
8
|
+
from .http import RequestTransport, default_request
|
|
9
|
+
from .jwt import JwtCodec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _raise_for_status(status: int, body: Any) -> None:
|
|
13
|
+
if 200 <= status < 300:
|
|
14
|
+
return
|
|
15
|
+
message = body if isinstance(body, str) else str(body)
|
|
16
|
+
raise AuthError(f"OIDC endpoint returned HTTP {status}: {message[:200]}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OidcClient:
|
|
20
|
+
"""A minimal OpenID Connect client (discovery + userinfo)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
issuer: str,
|
|
25
|
+
*,
|
|
26
|
+
client_id: str = "",
|
|
27
|
+
client_secret: str | None = None,
|
|
28
|
+
request: RequestTransport | None = None,
|
|
29
|
+
timeout: float = 30.0,
|
|
30
|
+
codec: JwtCodec | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._issuer = issuer.rstrip("/")
|
|
33
|
+
self._client_id = client_id
|
|
34
|
+
self._client_secret = client_secret
|
|
35
|
+
self._request = request or default_request
|
|
36
|
+
self._timeout = timeout
|
|
37
|
+
self._codec = codec
|
|
38
|
+
self._metadata: dict[str, Any] | None = None
|
|
39
|
+
|
|
40
|
+
def discovery(self, *, refresh: bool = False) -> dict[str, Any]:
|
|
41
|
+
"""Fetch and cache the OpenID configuration."""
|
|
42
|
+
if self._metadata is not None and not refresh:
|
|
43
|
+
return self._metadata
|
|
44
|
+
status, body = self._request(
|
|
45
|
+
"GET",
|
|
46
|
+
f"{self._issuer}/.well-known/openid-configuration",
|
|
47
|
+
timeout=self._timeout,
|
|
48
|
+
)
|
|
49
|
+
_raise_for_status(status, body)
|
|
50
|
+
if not isinstance(body, dict):
|
|
51
|
+
raise AuthError("OIDC discovery returned a non-object response")
|
|
52
|
+
self._metadata = body
|
|
53
|
+
return body
|
|
54
|
+
|
|
55
|
+
def userinfo(self, access_token: str) -> dict[str, Any]:
|
|
56
|
+
"""Fetch the userinfo endpoint with ``access_token``."""
|
|
57
|
+
metadata = self.discovery()
|
|
58
|
+
endpoint = metadata.get("userinfo_endpoint")
|
|
59
|
+
if not endpoint:
|
|
60
|
+
raise AuthError("OIDC discovery did not advertise a userinfo_endpoint")
|
|
61
|
+
status, body = self._request(
|
|
62
|
+
"GET",
|
|
63
|
+
endpoint,
|
|
64
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
65
|
+
timeout=self._timeout,
|
|
66
|
+
)
|
|
67
|
+
_raise_for_status(status, body)
|
|
68
|
+
return dict(body)
|
|
69
|
+
|
|
70
|
+
def verify_id_token(self, id_token: str) -> dict[str, Any]:
|
|
71
|
+
"""Decode and verify an ``id_token`` (HS256 via client_secret)."""
|
|
72
|
+
if self._codec is not None:
|
|
73
|
+
return self._codec.decode(id_token, verify=True)
|
|
74
|
+
if not self._client_secret:
|
|
75
|
+
raise AuthError(
|
|
76
|
+
"cannot verify id_token: no client_secret (HS256) or codec configured"
|
|
77
|
+
)
|
|
78
|
+
codec = JwtCodec(self._client_secret)
|
|
79
|
+
claims = codec.decode(id_token, verify=True)
|
|
80
|
+
audience = claims.get("aud")
|
|
81
|
+
if self._client_id and audience not in (self._client_id, [self._client_id]):
|
|
82
|
+
raise AuthError(f"id_token audience mismatch: {audience}")
|
|
83
|
+
return claims
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Auth plugin entry point (RFC-0019, M9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from xyberos.contracts import Plugin, Tool
|
|
9
|
+
from xyberos.tools import FunctionTool
|
|
10
|
+
|
|
11
|
+
from .jwt import JwtCodec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _pop_tool(registry: Any, name: str) -> None:
|
|
15
|
+
unregister = getattr(registry, "unregister", None)
|
|
16
|
+
if callable(unregister):
|
|
17
|
+
unregister(name)
|
|
18
|
+
return
|
|
19
|
+
store = getattr(registry, "_tools", None)
|
|
20
|
+
if isinstance(store, dict):
|
|
21
|
+
cast(dict[str, Any], store).pop(name, None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthPlugin(Plugin):
|
|
25
|
+
"""Registers JWT sign/verify tools backed by a configured :class:`JwtCodec`.
|
|
26
|
+
|
|
27
|
+
``secret`` comes from ``AUTH_JWT_SECRET`` (HS256) or the ``private_key`` /
|
|
28
|
+
``public_key`` args (RS256). If none is configured the plugin registers
|
|
29
|
+
nothing (logs a warning).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
secret: str | None = None,
|
|
35
|
+
*,
|
|
36
|
+
algorithm: str = "HS256",
|
|
37
|
+
private_key: str | None = None,
|
|
38
|
+
public_key: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._secret = secret if secret is not None else os.getenv("AUTH_JWT_SECRET")
|
|
41
|
+
self._algorithm = algorithm
|
|
42
|
+
self._private_key = private_key or os.getenv("AUTH_JWT_PRIVATE_KEY")
|
|
43
|
+
self._public_key = public_key or os.getenv("AUTH_JWT_PUBLIC_KEY")
|
|
44
|
+
self._codec: JwtCodec | None = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def name(self) -> str:
|
|
48
|
+
return "auth"
|
|
49
|
+
|
|
50
|
+
def jwt_codec(self) -> JwtCodec:
|
|
51
|
+
if self._codec is None:
|
|
52
|
+
self._codec = JwtCodec(
|
|
53
|
+
self._secret,
|
|
54
|
+
algorithm=self._algorithm,
|
|
55
|
+
private_key=self._private_key,
|
|
56
|
+
public_key=self._public_key,
|
|
57
|
+
)
|
|
58
|
+
return self._codec
|
|
59
|
+
|
|
60
|
+
def tools(self) -> list[Tool]:
|
|
61
|
+
def _sign(payload: dict[str, Any], ttl: int = 3600) -> str:
|
|
62
|
+
return self.jwt_codec().encode(payload, ttl=ttl)
|
|
63
|
+
|
|
64
|
+
def _verify(token: str) -> dict[str, Any]:
|
|
65
|
+
return self.jwt_codec().decode(token, verify=True)
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
FunctionTool("jwt_sign", _sign, description="Sign a payload as a JWT."),
|
|
69
|
+
FunctionTool("jwt_verify", _verify, description="Verify and decode a JWT."),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def register(self, kernel: object) -> None:
|
|
73
|
+
try:
|
|
74
|
+
self.jwt_codec()
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
logger = getattr(kernel, "logger", None)
|
|
77
|
+
if logger is not None and callable(getattr(logger, "warning", None)):
|
|
78
|
+
logger.warning("auth plugin not configured: %s", exc)
|
|
79
|
+
return
|
|
80
|
+
registry = kernel.resolve("tools")
|
|
81
|
+
for tool in self.tools():
|
|
82
|
+
registry.register(tool)
|
|
83
|
+
|
|
84
|
+
def unregister(self, kernel: object) -> None:
|
|
85
|
+
registry = kernel.resolve("tools")
|
|
86
|
+
for tool in self.tools():
|
|
87
|
+
_pop_tool(registry, tool.name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#: Auto-discovered by ``app.load_entry_points()``.
|
|
91
|
+
plugin = AuthPlugin()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Provider presets for Auth0, Okta and Microsoft Entra (OIDC)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .oidc import OidcClient
|
|
6
|
+
|
|
7
|
+
#: preset name -> issuer template. ``{tenant}`` / ``{org}`` are substituted.
|
|
8
|
+
OIDC_PRESETS: dict[str, str] = {
|
|
9
|
+
"auth0": "https://{tenant}.auth0.com",
|
|
10
|
+
"okta": "https://{org}.okta.com/oauth2/default",
|
|
11
|
+
"entra": "https://login.microsoftonline.com/{tenant}/v2.0",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_preset(name: str, **kwargs: str) -> str:
|
|
16
|
+
"""Return the issuer for a named OIDC preset, filling in placeholders."""
|
|
17
|
+
key = name.lower()
|
|
18
|
+
if key not in OIDC_PRESETS:
|
|
19
|
+
raise ValueError(f"unknown OIDC preset '{name}' (choose from {sorted(OIDC_PRESETS)})")
|
|
20
|
+
template = OIDC_PRESETS[key]
|
|
21
|
+
missing = [token for token in ("{tenant}", "{org}") if token in template and token[1:-1] not in kwargs]
|
|
22
|
+
if missing:
|
|
23
|
+
raise ValueError(f"preset '{name}' requires a '{missing[0][1:-1]}' value")
|
|
24
|
+
return template.format(**kwargs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_oidc(preset: str, client_id: str, client_secret: str | None = None, **kwargs: str) -> OidcClient:
|
|
28
|
+
"""Build an :class:`OidcClient` for a named preset."""
|
|
29
|
+
issuer = get_preset(preset, **kwargs)
|
|
30
|
+
return OidcClient(issuer, client_id=client_id, client_secret=client_secret)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Auth plugin (RFC-0019, M9): OAuth2, OIDC, JWT with Auth0/Okta/Entra presets
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,auth,oauth2,oidc,jwt,sso
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: rsa
|
|
11
|
+
Requires-Dist: cryptography; extra == "rsa"
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
|
14
|
+
|
|
15
|
+
# xyberos-auth
|
|
16
|
+
|
|
17
|
+
**Auth plugin — RFC-0019, M9.** OAuth 2.0, OpenID Connect, and JWT, with
|
|
18
|
+
Auth0 / Okta / Microsoft Entra presets. All stdlib (`urllib` + `hmac`); RS256
|
|
19
|
+
uses lazy `cryptography`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e ./auth
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## JWT
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from xyberos_auth import JwtCodec
|
|
31
|
+
|
|
32
|
+
codec = JwtCodec("a-shared-secret")
|
|
33
|
+
token = codec.encode({"sub": "user-1"}, ttl=3600)
|
|
34
|
+
codec.decode(token, verify=True) # raises AuthError on tamper/expiry
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or through the plugin (HS256 via `AUTH_JWT_SECRET`):
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from xyberos import create_app
|
|
41
|
+
from xyberos_auth import AuthPlugin
|
|
42
|
+
|
|
43
|
+
app = create_app()
|
|
44
|
+
app.load_plugin(AuthPlugin(secret="dev-secret"))
|
|
45
|
+
app.tools.execute("jwt_sign", None, payload={"sub": "user-1"}, ttl=600)
|
|
46
|
+
app.tools.execute("jwt_verify", None, token=token)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## OAuth2 / OIDC
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from xyberos_auth import OAuth2Client, build_oidc
|
|
53
|
+
|
|
54
|
+
oauth2 = OAuth2Client("cid", "csecret",
|
|
55
|
+
authorize_url="https://idp/authorize",
|
|
56
|
+
token_url="https://idp/token", scope="openid profile")
|
|
57
|
+
url = oauth2.authorization_url(state="abc")
|
|
58
|
+
tokens = oauth2.exchange_code("code")
|
|
59
|
+
|
|
60
|
+
oidc = build_oidc("auth0", client_id="cid", client_secret="csecret", tenant="myco")
|
|
61
|
+
user = oidc.userinfo(tokens["access_token"])
|
|
62
|
+
claims = oidc.verify_id_token(tokens.get("id_token", ""))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Presets: `auth0` (`{tenant}`), `okta` (`{org}`), `entra` (`{tenant}`).
|
|
66
|
+
|
|
67
|
+
## Tests
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install pytest
|
|
71
|
+
pytest tests/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Transport-injectable (OAuth2/OIDC) + real HMAC/RSA JWT tests (skip RS256 when
|
|
75
|
+
`cryptography` is absent).
|
|
76
|
+
|
|
77
|
+
## Ship location
|
|
78
|
+
|
|
79
|
+
Plugin (`xyberos.plugins` entry point) — enterprise auth (M9).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_jwt.py
|
|
4
|
+
tests/test_oauth2.py
|
|
5
|
+
tests/test_oidc.py
|
|
6
|
+
tests/test_plugin.py
|
|
7
|
+
xyberos_auth/__init__.py
|
|
8
|
+
xyberos_auth/errors.py
|
|
9
|
+
xyberos_auth/http.py
|
|
10
|
+
xyberos_auth/jwt.py
|
|
11
|
+
xyberos_auth/oauth2.py
|
|
12
|
+
xyberos_auth/oidc.py
|
|
13
|
+
xyberos_auth/plugin.py
|
|
14
|
+
xyberos_auth/presets.py
|
|
15
|
+
xyberos_auth.egg-info/PKG-INFO
|
|
16
|
+
xyberos_auth.egg-info/SOURCES.txt
|
|
17
|
+
xyberos_auth.egg-info/dependency_links.txt
|
|
18
|
+
xyberos_auth.egg-info/entry_points.txt
|
|
19
|
+
xyberos_auth.egg-info/requires.txt
|
|
20
|
+
xyberos_auth.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xyberos_auth
|