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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ """Errors raised by the auth plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AuthError(Exception):
7
+ """An OAuth/OIDC/JWT failure."""
@@ -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,2 @@
1
+ [xyberos.plugins]
2
+ auth = xyberos_auth.plugin:plugin
@@ -0,0 +1,7 @@
1
+ xyberos>=1.0
2
+
3
+ [rsa]
4
+ cryptography
5
+
6
+ [test]
7
+ pytest
@@ -0,0 +1 @@
1
+ xyberos_auth