zisu-app-sdk 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,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: zisu-app-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Zisu OIDC and embedded launch ticket login.
5
+ Author: Zisu
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: fastapi
9
+ Requires-Dist: fastapi>=0.100; extra == "fastapi"
10
+ Requires-Dist: starlette>=0.27; extra == "fastapi"
11
+
12
+ # zisu-app-sdk
13
+
14
+ Python SDK for Zisu OIDC login and embedded iframe launch ticket login.
15
+
16
+ ## Core usage
17
+
18
+ ```python
19
+ from zisu_app_sdk import ZisuOIDCClient
20
+
21
+ client = ZisuOIDCClient(
22
+ issuer="http://127.0.0.1:3005",
23
+ client_id="essay-helper",
24
+ client_secret="zisu-dev-secret",
25
+ redirect_uri="http://127.0.0.1:8000/auth/zisu/callback",
26
+ )
27
+
28
+ auth = client.get_authorization_url()
29
+ print(auth.url)
30
+
31
+ result = client.exchange_launch_ticket(
32
+ launch_ticket="...",
33
+ expected_nonce="nonce-from-frame",
34
+ )
35
+ print(result.user.sub)
36
+ ```
37
+
38
+ ## FastAPI helper
39
+
40
+ ```python
41
+ from fastapi import FastAPI
42
+ from zisu_app_sdk import ZisuFastAPIAuth, ZisuOIDCClient
43
+
44
+ app = FastAPI()
45
+ client = ZisuOIDCClient(
46
+ issuer="http://127.0.0.1:3005",
47
+ client_id="essay-helper",
48
+ client_secret="zisu-dev-secret",
49
+ redirect_uri="http://127.0.0.1:8000/auth/zisu/callback",
50
+ )
51
+
52
+ auth = ZisuFastAPIAuth(client, app_session_secret="change-me")
53
+ app.include_router(auth.router())
54
+ ```
55
+
56
+ Set `cookie_secure=True` when the app is served over HTTPS.
57
+
58
+ Routes added by the helper:
59
+
60
+ - `GET /login/zisu`
61
+ - `GET /auth/zisu/callback`
62
+ - `POST /auth/zisu/embed`
63
+ - `GET /api/me`
@@ -0,0 +1,52 @@
1
+ # zisu-app-sdk
2
+
3
+ Python SDK for Zisu OIDC login and embedded iframe launch ticket login.
4
+
5
+ ## Core usage
6
+
7
+ ```python
8
+ from zisu_app_sdk import ZisuOIDCClient
9
+
10
+ client = ZisuOIDCClient(
11
+ issuer="http://127.0.0.1:3005",
12
+ client_id="essay-helper",
13
+ client_secret="zisu-dev-secret",
14
+ redirect_uri="http://127.0.0.1:8000/auth/zisu/callback",
15
+ )
16
+
17
+ auth = client.get_authorization_url()
18
+ print(auth.url)
19
+
20
+ result = client.exchange_launch_ticket(
21
+ launch_ticket="...",
22
+ expected_nonce="nonce-from-frame",
23
+ )
24
+ print(result.user.sub)
25
+ ```
26
+
27
+ ## FastAPI helper
28
+
29
+ ```python
30
+ from fastapi import FastAPI
31
+ from zisu_app_sdk import ZisuFastAPIAuth, ZisuOIDCClient
32
+
33
+ app = FastAPI()
34
+ client = ZisuOIDCClient(
35
+ issuer="http://127.0.0.1:3005",
36
+ client_id="essay-helper",
37
+ client_secret="zisu-dev-secret",
38
+ redirect_uri="http://127.0.0.1:8000/auth/zisu/callback",
39
+ )
40
+
41
+ auth = ZisuFastAPIAuth(client, app_session_secret="change-me")
42
+ app.include_router(auth.router())
43
+ ```
44
+
45
+ Set `cookie_secure=True` when the app is served over HTTPS.
46
+
47
+ Routes added by the helper:
48
+
49
+ - `GET /login/zisu`
50
+ - `GET /auth/zisu/callback`
51
+ - `POST /auth/zisu/embed`
52
+ - `GET /api/me`
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zisu-app-sdk"
7
+ version = "0.1.0"
8
+ description = "Python SDK for Zisu OIDC and embedded launch ticket login."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [
12
+ { name = "Zisu" }
13
+ ]
14
+ dependencies = []
15
+
16
+ [project.optional-dependencies]
17
+ fastapi = ["fastapi>=0.100", "starlette>=0.27"]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.pytest.ini_options]
23
+ pythonpath = ["src"]
24
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ from .client import ZisuOIDCClient
2
+ from .errors import ZisuAuthError, ZisuHTTPError, ZisuTokenError
3
+ from .fastapi import ZisuFastAPIAuth
4
+ from .models import AuthorizationURL, ZisuLoginResult, ZisuTokenSet, ZisuUser
5
+
6
+ __all__ = [
7
+ "AuthorizationURL",
8
+ "ZisuAuthError",
9
+ "ZisuFastAPIAuth",
10
+ "ZisuHTTPError",
11
+ "ZisuLoginResult",
12
+ "ZisuOIDCClient",
13
+ "ZisuTokenError",
14
+ "ZisuTokenSet",
15
+ "ZisuUser",
16
+ ]
@@ -0,0 +1,165 @@
1
+ import secrets
2
+ from typing import Any, Dict, Mapping, Optional
3
+ from urllib import parse
4
+
5
+ from .http import UrllibHTTPClient
6
+ from .jwt import verify_id_token as verify_jwt_id_token
7
+ from .models import AuthorizationURL, ZisuLoginResult, ZisuTokenSet, ZisuUser
8
+
9
+ LAUNCH_TICKET_GRANT_TYPE = "urn:zisu:oauth:grant-type:launch_ticket"
10
+
11
+
12
+ class ZisuOIDCClient:
13
+ def __init__(
14
+ self,
15
+ issuer: str,
16
+ client_id: str,
17
+ client_secret: str,
18
+ redirect_uri: Optional[str] = None,
19
+ scope: str = "openid profile email",
20
+ timeout: float = 10.0,
21
+ http_client: Optional[Any] = None,
22
+ ):
23
+ self.issuer = issuer.rstrip("/")
24
+ self.client_id = client_id
25
+ self.client_secret = client_secret
26
+ self.redirect_uri = redirect_uri
27
+ self.scope = scope
28
+ self.http = http_client or UrllibHTTPClient(timeout=timeout)
29
+ self._discovery: Optional[Dict[str, Any]] = None
30
+ self._jwks: Optional[Dict[str, Any]] = None
31
+
32
+ def discovery(self) -> Dict[str, Any]:
33
+ if self._discovery is None:
34
+ self._discovery = self.http.get_json(f"{self.issuer}/.well-known/openid-configuration")
35
+ return self._discovery
36
+
37
+ def jwks(self) -> Dict[str, Any]:
38
+ if self._jwks is None:
39
+ self._jwks = self.http.get_json(self.discovery()["jwks_uri"])
40
+ return self._jwks
41
+
42
+ def get_authorization_url(
43
+ self,
44
+ state: Optional[str] = None,
45
+ nonce: Optional[str] = None,
46
+ scope: Optional[str] = None,
47
+ redirect_uri: Optional[str] = None,
48
+ extra_params: Optional[Mapping[str, str]] = None,
49
+ ) -> AuthorizationURL:
50
+ state = state or secrets.token_urlsafe(24)
51
+ nonce = nonce or secrets.token_urlsafe(24)
52
+ redirect_uri = redirect_uri or self.redirect_uri
53
+ if not redirect_uri:
54
+ raise ValueError("redirect_uri is required")
55
+
56
+ params = {
57
+ "response_type": "code",
58
+ "client_id": self.client_id,
59
+ "redirect_uri": redirect_uri,
60
+ "scope": scope or self.scope,
61
+ "state": state,
62
+ "nonce": nonce,
63
+ }
64
+ if extra_params:
65
+ params.update(extra_params)
66
+
67
+ endpoint = self.discovery()["authorization_endpoint"]
68
+ return AuthorizationURL(f"{endpoint}?{parse.urlencode(params)}", state, nonce)
69
+
70
+ def handle_callback(
71
+ self,
72
+ code: str,
73
+ state: str,
74
+ expected_state: str,
75
+ expected_nonce: str,
76
+ redirect_uri: Optional[str] = None,
77
+ ) -> ZisuLoginResult:
78
+ if not expected_state or state != expected_state:
79
+ from .errors import ZisuTokenError
80
+
81
+ raise ZisuTokenError("invalid oauth state")
82
+
83
+ token_set = self.exchange_code(code, redirect_uri=redirect_uri)
84
+ return self._login_result(token_set, expected_nonce=expected_nonce)
85
+
86
+ def exchange_code(self, code: str, redirect_uri: Optional[str] = None) -> ZisuTokenSet:
87
+ redirect_uri = redirect_uri or self.redirect_uri
88
+ if not redirect_uri:
89
+ raise ValueError("redirect_uri is required")
90
+
91
+ data = self.http.post_form(
92
+ self.discovery()["token_endpoint"],
93
+ {
94
+ "grant_type": "authorization_code",
95
+ "client_id": self.client_id,
96
+ "client_secret": self.client_secret,
97
+ "code": code,
98
+ "redirect_uri": redirect_uri,
99
+ },
100
+ )
101
+ return self._token_set(data)
102
+
103
+ def exchange_launch_ticket(
104
+ self,
105
+ launch_ticket: str,
106
+ expected_nonce: Optional[str] = None,
107
+ ) -> ZisuLoginResult:
108
+ data = self.http.post_form(
109
+ self.discovery()["token_endpoint"],
110
+ {
111
+ "grant_type": LAUNCH_TICKET_GRANT_TYPE,
112
+ "client_id": self.client_id,
113
+ "client_secret": self.client_secret,
114
+ "launch_ticket": launch_ticket,
115
+ },
116
+ )
117
+ return self._login_result(self._token_set(data), expected_nonce=expected_nonce)
118
+
119
+ def verify_id_token(self, id_token: str, expected_nonce: Optional[str] = None) -> Dict[str, Any]:
120
+ return verify_jwt_id_token(
121
+ id_token,
122
+ jwks=self.jwks(),
123
+ issuer=self.discovery()["issuer"],
124
+ audience=self.client_id,
125
+ nonce=expected_nonce,
126
+ )
127
+
128
+ def fetch_userinfo(self, access_token: str) -> Dict[str, Any]:
129
+ return self.http.get_json(
130
+ self.discovery()["userinfo_endpoint"],
131
+ headers={"Authorization": f"Bearer {access_token}"},
132
+ )
133
+
134
+ def _login_result(self, token_set: ZisuTokenSet, expected_nonce: Optional[str]) -> ZisuLoginResult:
135
+ claims = self.verify_id_token(token_set.id_token, expected_nonce=expected_nonce)
136
+ userinfo = self.fetch_userinfo(token_set.access_token)
137
+ user = self._user_from_claims(claims, userinfo)
138
+ return ZisuLoginResult(user=user, tokens=token_set, id_token_claims=claims, userinfo=userinfo)
139
+
140
+ def _token_set(self, data: Dict[str, Any]) -> ZisuTokenSet:
141
+ if not data.get("access_token") or not data.get("id_token"):
142
+ from .errors import ZisuTokenError
143
+
144
+ raise ZisuTokenError("token response missing access_token or id_token")
145
+ return ZisuTokenSet(
146
+ access_token=data["access_token"],
147
+ id_token=data["id_token"],
148
+ token_type=data.get("token_type", "Bearer"),
149
+ expires_in=int(data.get("expires_in", 0)),
150
+ scope=data.get("scope", ""),
151
+ refresh_token=data.get("refresh_token"),
152
+ raw=dict(data),
153
+ )
154
+
155
+ def _user_from_claims(self, claims: Dict[str, Any], userinfo: Dict[str, Any]) -> ZisuUser:
156
+ merged = dict(claims)
157
+ merged.update({k: v for k, v in userinfo.items() if v not in (None, "")})
158
+ return ZisuUser(
159
+ sub=str(merged.get("sub", "")),
160
+ name=merged.get("name", ""),
161
+ email=merged.get("email", ""),
162
+ avatar_url=merged.get("avatar_url", ""),
163
+ org_id=merged.get("org_id", ""),
164
+ claims=merged,
165
+ )
@@ -0,0 +1,14 @@
1
+ class ZisuAuthError(Exception):
2
+ """Base SDK error."""
3
+
4
+
5
+ class ZisuHTTPError(ZisuAuthError):
6
+ def __init__(self, status_code: int, body: str):
7
+ super().__init__(f"zisu http error {status_code}: {body}")
8
+ self.status_code = status_code
9
+ self.body = body
10
+
11
+
12
+ class ZisuTokenError(ZisuAuthError):
13
+ """Raised when an ID token or app session token is invalid."""
14
+
@@ -0,0 +1,123 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .jwt import sign_session, verify_session
4
+
5
+
6
+ class ZisuFastAPIAuth:
7
+ def __init__(
8
+ self,
9
+ client: Any,
10
+ app_session_secret: str,
11
+ success_redirect_path: str = "/",
12
+ session_cookie_name: str = "zisu_app_session",
13
+ oauth_state_cookie_name: str = "zisu_oauth_state",
14
+ oauth_nonce_cookie_name: str = "zisu_oauth_nonce",
15
+ session_expires_in: int = 60 * 60 * 8,
16
+ cookie_secure: bool = False,
17
+ ):
18
+ if not app_session_secret:
19
+ raise ValueError("app_session_secret is required")
20
+ self.client = client
21
+ self.app_session_secret = app_session_secret
22
+ self.success_redirect_path = success_redirect_path
23
+ self.session_cookie_name = session_cookie_name
24
+ self.oauth_state_cookie_name = oauth_state_cookie_name
25
+ self.oauth_nonce_cookie_name = oauth_nonce_cookie_name
26
+ self.session_expires_in = session_expires_in
27
+ self.cookie_secure = cookie_secure
28
+
29
+ def router(self):
30
+ try:
31
+ from fastapi import APIRouter, HTTPException, Request, Response
32
+ from fastapi.responses import JSONResponse, RedirectResponse
33
+ except ImportError as exc:
34
+ raise RuntimeError("Install zisu-app-sdk[fastapi] to use FastAPI helpers") from exc
35
+
36
+ router = APIRouter()
37
+
38
+ @router.get("/login/zisu")
39
+ def login_zisu():
40
+ auth = self.client.get_authorization_url()
41
+ resp = RedirectResponse(auth.url, status_code=302)
42
+ _set_temp_cookie(resp, self.oauth_state_cookie_name, auth.state, self.cookie_secure)
43
+ _set_temp_cookie(resp, self.oauth_nonce_cookie_name, auth.nonce, self.cookie_secure)
44
+ return resp
45
+
46
+ @router.get("/auth/zisu/callback")
47
+ def zisu_callback(request: Request, code: str, state: str):
48
+ expected_state = request.cookies.get(self.oauth_state_cookie_name, "")
49
+ expected_nonce = request.cookies.get(self.oauth_nonce_cookie_name, "")
50
+ result = self.client.handle_callback(code, state, expected_state, expected_nonce)
51
+ token = self.create_session_token(result.user.claims)
52
+ resp = RedirectResponse(self.success_redirect_path, status_code=302)
53
+ _set_session_cookie(resp, self.session_cookie_name, token, self.session_expires_in, self.cookie_secure)
54
+ resp.delete_cookie(self.oauth_state_cookie_name)
55
+ resp.delete_cookie(self.oauth_nonce_cookie_name)
56
+ return resp
57
+
58
+ @router.post("/auth/zisu/embed")
59
+ async def zisu_embed(request: Request, response: Response):
60
+ body = await request.json()
61
+ result = self.client.exchange_launch_ticket(
62
+ launch_ticket=body.get("launch_ticket", ""),
63
+ expected_nonce=body.get("frame_nonce"),
64
+ )
65
+ token = self.create_session_token(result.user.claims)
66
+ _set_session_cookie(response, self.session_cookie_name, token, self.session_expires_in, self.cookie_secure)
67
+ return {
68
+ "session_token": token,
69
+ "expires_in": self.session_expires_in,
70
+ "user": _public_user(result.user.claims),
71
+ }
72
+
73
+ @router.get("/api/me")
74
+ def me(request: Request):
75
+ user = self.current_user(request)
76
+ if not user:
77
+ raise HTTPException(status_code=401, detail="unauthorized")
78
+ return JSONResponse({"user": _public_user(user)})
79
+
80
+ return router
81
+
82
+ def create_session_token(self, claims: Dict[str, Any]) -> str:
83
+ payload = {
84
+ "sub": claims.get("sub", ""),
85
+ "name": claims.get("name", ""),
86
+ "email": claims.get("email", ""),
87
+ "avatar_url": claims.get("avatar_url", ""),
88
+ "org_id": claims.get("org_id", ""),
89
+ }
90
+ return sign_session(payload, self.app_session_secret, self.session_expires_in)
91
+
92
+ def current_user(self, request: Any) -> Optional[Dict[str, Any]]:
93
+ token = _bearer_token(request.headers.get("authorization", ""))
94
+ if not token:
95
+ token = request.cookies.get(self.session_cookie_name, "")
96
+ if not token:
97
+ return None
98
+ return verify_session(token, self.app_session_secret)
99
+
100
+
101
+ def _bearer_token(header: str) -> str:
102
+ prefix = "bearer "
103
+ if header.lower().startswith(prefix):
104
+ return header[len(prefix) :].strip()
105
+ return ""
106
+
107
+
108
+ def _set_temp_cookie(response: Any, name: str, value: str, secure: bool) -> None:
109
+ response.set_cookie(name, value, max_age=600, httponly=True, secure=secure, samesite="lax")
110
+
111
+
112
+ def _set_session_cookie(response: Any, name: str, value: str, max_age: int, secure: bool) -> None:
113
+ response.set_cookie(name, value, max_age=max_age, httponly=True, secure=secure, samesite="lax")
114
+
115
+
116
+ def _public_user(claims: Dict[str, Any]) -> Dict[str, Any]:
117
+ return {
118
+ "sub": claims.get("sub", ""),
119
+ "name": claims.get("name", ""),
120
+ "email": claims.get("email", ""),
121
+ "avatar_url": claims.get("avatar_url", ""),
122
+ "org_id": claims.get("org_id", ""),
123
+ }
@@ -0,0 +1,39 @@
1
+ import json
2
+ from typing import Any, Dict, Mapping, Optional
3
+ from urllib import parse, request
4
+ from urllib.error import HTTPError
5
+
6
+ from .errors import ZisuHTTPError
7
+
8
+
9
+ class UrllibHTTPClient:
10
+ def __init__(self, timeout: float = 10.0):
11
+ self.timeout = timeout
12
+
13
+ def get_json(self, url: str, headers: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
14
+ req = request.Request(url, headers=dict(headers or {}), method="GET")
15
+ return self._send_json(req)
16
+
17
+ def post_form(
18
+ self,
19
+ url: str,
20
+ data: Mapping[str, str],
21
+ headers: Optional[Mapping[str, str]] = None,
22
+ ) -> Dict[str, Any]:
23
+ body = parse.urlencode(data).encode("utf-8")
24
+ merged = {"Content-Type": "application/x-www-form-urlencoded"}
25
+ merged.update(headers or {})
26
+ req = request.Request(url, data=body, headers=merged, method="POST")
27
+ return self._send_json(req)
28
+
29
+ def _send_json(self, req: request.Request) -> Dict[str, Any]:
30
+ try:
31
+ with request.urlopen(req, timeout=self.timeout) as resp:
32
+ raw = resp.read().decode("utf-8")
33
+ except HTTPError as exc:
34
+ body = exc.read().decode("utf-8", errors="replace")
35
+ raise ZisuHTTPError(exc.code, body) from exc
36
+
37
+ if not raw:
38
+ return {}
39
+ return json.loads(raw)
@@ -0,0 +1,160 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import time
6
+ from typing import Any, Dict, Iterable, Optional, Tuple
7
+
8
+ from .errors import ZisuTokenError
9
+
10
+ _SHA256_DER_PREFIX = bytes.fromhex("3031300d060960864801650304020105000420")
11
+
12
+
13
+ def b64url_decode(value: str) -> bytes:
14
+ padding = "=" * (-len(value) % 4)
15
+ return base64.urlsafe_b64decode((value + padding).encode("ascii"))
16
+
17
+
18
+ def b64url_encode(value: bytes) -> str:
19
+ return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
20
+
21
+
22
+ def parse_jwt(token: str) -> Tuple[Dict[str, Any], Dict[str, Any], bytes, bytes]:
23
+ parts = token.split(".")
24
+ if len(parts) != 3:
25
+ raise ZisuTokenError("invalid jwt format")
26
+
27
+ header_segment, payload_segment, signature_segment = parts
28
+ try:
29
+ header = json.loads(b64url_decode(header_segment))
30
+ claims = json.loads(b64url_decode(payload_segment))
31
+ except (ValueError, TypeError) as exc:
32
+ raise ZisuTokenError("invalid jwt json") from exc
33
+
34
+ signed = f"{header_segment}.{payload_segment}".encode("ascii")
35
+ signature = b64url_decode(signature_segment)
36
+ return header, claims, signed, signature
37
+
38
+
39
+ def verify_id_token(
40
+ token: str,
41
+ jwks: Dict[str, Any],
42
+ issuer: str,
43
+ audience: str,
44
+ nonce: Optional[str] = None,
45
+ leeway: int = 60,
46
+ ) -> Dict[str, Any]:
47
+ header, claims, signed, signature = parse_jwt(token)
48
+
49
+ if header.get("alg") != "RS256":
50
+ raise ZisuTokenError("unsupported id_token alg")
51
+
52
+ jwk = _select_jwk(jwks.get("keys", []), header.get("kid"))
53
+ _verify_rs256_signature(signed, signature, jwk)
54
+ _validate_claims(claims, issuer, audience, nonce, leeway)
55
+ return claims
56
+
57
+
58
+ def sign_session(payload: Dict[str, Any], secret: str, expires_in: int) -> str:
59
+ now = int(time.time())
60
+ body = dict(payload)
61
+ body["iat"] = now
62
+ body["exp"] = now + expires_in
63
+
64
+ header = {"alg": "HS256", "typ": "JWT"}
65
+ header_segment = b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
66
+ payload_segment = b64url_encode(json.dumps(body, separators=(",", ":")).encode("utf-8"))
67
+ signed = f"{header_segment}.{payload_segment}".encode("ascii")
68
+ signature = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
69
+ return f"{header_segment}.{payload_segment}.{b64url_encode(signature)}"
70
+
71
+
72
+ def verify_session(token: str, secret: str, leeway: int = 30) -> Dict[str, Any]:
73
+ header, claims, signed, signature = parse_jwt(token)
74
+ if header.get("alg") != "HS256":
75
+ raise ZisuTokenError("unsupported app session alg")
76
+ expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
77
+ if not hmac.compare_digest(expected, signature):
78
+ raise ZisuTokenError("invalid app session signature")
79
+
80
+ now = int(time.time())
81
+ exp = int(claims.get("exp", 0))
82
+ if exp <= now - leeway:
83
+ raise ZisuTokenError("app session expired")
84
+ return claims
85
+
86
+
87
+ def _select_jwk(keys: Iterable[Dict[str, Any]], kid: Optional[str]) -> Dict[str, Any]:
88
+ fallback = None
89
+ for key in keys:
90
+ if key.get("kty") != "RSA":
91
+ continue
92
+ if fallback is None:
93
+ fallback = key
94
+ if kid and key.get("kid") == kid:
95
+ return key
96
+ if fallback and not kid:
97
+ return fallback
98
+ raise ZisuTokenError("matching jwk not found")
99
+
100
+
101
+ def _verify_rs256_signature(signed: bytes, signature: bytes, jwk: Dict[str, Any]) -> None:
102
+ n = int.from_bytes(b64url_decode(jwk["n"]), "big")
103
+ e = int.from_bytes(b64url_decode(jwk["e"]), "big")
104
+ sig_int = int.from_bytes(signature, "big")
105
+ key_size = (n.bit_length() + 7) // 8
106
+ encoded = pow(sig_int, e, n).to_bytes(key_size, "big")
107
+
108
+ digest = hashlib.sha256(signed).digest()
109
+ expected_suffix = _SHA256_DER_PREFIX + digest
110
+ if not encoded.startswith(b"\x00\x01") or b"\x00" not in encoded[2:]:
111
+ raise ZisuTokenError("invalid id_token signature")
112
+ padding, suffix = encoded[2:].split(b"\x00", 1)
113
+ if len(padding) < 8 or any(item != 0xFF for item in padding):
114
+ raise ZisuTokenError("invalid id_token signature")
115
+ if not hmac.compare_digest(suffix, expected_suffix):
116
+ raise ZisuTokenError("invalid id_token signature")
117
+
118
+
119
+ def _validate_claims(
120
+ claims: Dict[str, Any],
121
+ issuer: str,
122
+ audience: str,
123
+ nonce: Optional[str],
124
+ leeway: int,
125
+ ) -> None:
126
+ now = int(time.time())
127
+ if claims.get("iss") != issuer:
128
+ raise ZisuTokenError("invalid id_token issuer")
129
+
130
+ aud = claims.get("aud")
131
+ if isinstance(aud, str):
132
+ valid_audience = aud == audience
133
+ elif isinstance(aud, list):
134
+ valid_audience = audience in aud
135
+ else:
136
+ valid_audience = False
137
+ if not valid_audience:
138
+ raise ZisuTokenError("invalid id_token audience")
139
+
140
+ try:
141
+ exp = int(claims.get("exp", 0))
142
+ except (TypeError, ValueError) as exc:
143
+ raise ZisuTokenError("invalid id_token exp") from exc
144
+ if exp <= now - leeway:
145
+ raise ZisuTokenError("id_token expired")
146
+
147
+ nbf = claims.get("nbf")
148
+ if nbf is not None:
149
+ try:
150
+ nbf_value = int(nbf)
151
+ except (TypeError, ValueError) as exc:
152
+ raise ZisuTokenError("invalid id_token nbf") from exc
153
+ if nbf_value > now + leeway:
154
+ raise ZisuTokenError("id_token not active yet")
155
+
156
+ if nonce is not None and claims.get("nonce") != nonce:
157
+ raise ZisuTokenError("invalid id_token nonce")
158
+
159
+ if not claims.get("sub"):
160
+ raise ZisuTokenError("missing id_token subject")
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Dict, Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class AuthorizationURL:
7
+ url: str
8
+ state: str
9
+ nonce: str
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ZisuTokenSet:
14
+ access_token: str
15
+ id_token: str
16
+ token_type: str = "Bearer"
17
+ expires_in: int = 0
18
+ scope: str = ""
19
+ refresh_token: Optional[str] = None
20
+ raw: Dict[str, Any] = field(default_factory=dict)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ZisuUser:
25
+ sub: str
26
+ name: str = ""
27
+ email: str = ""
28
+ avatar_url: str = ""
29
+ org_id: str = ""
30
+ claims: Dict[str, Any] = field(default_factory=dict)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ZisuLoginResult:
35
+ user: ZisuUser
36
+ tokens: ZisuTokenSet
37
+ id_token_claims: Dict[str, Any]
38
+ userinfo: Dict[str, Any] = field(default_factory=dict)
39
+
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: zisu-app-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Zisu OIDC and embedded launch ticket login.
5
+ Author: Zisu
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: fastapi
9
+ Requires-Dist: fastapi>=0.100; extra == "fastapi"
10
+ Requires-Dist: starlette>=0.27; extra == "fastapi"
11
+
12
+ # zisu-app-sdk
13
+
14
+ Python SDK for Zisu OIDC login and embedded iframe launch ticket login.
15
+
16
+ ## Core usage
17
+
18
+ ```python
19
+ from zisu_app_sdk import ZisuOIDCClient
20
+
21
+ client = ZisuOIDCClient(
22
+ issuer="http://127.0.0.1:3005",
23
+ client_id="essay-helper",
24
+ client_secret="zisu-dev-secret",
25
+ redirect_uri="http://127.0.0.1:8000/auth/zisu/callback",
26
+ )
27
+
28
+ auth = client.get_authorization_url()
29
+ print(auth.url)
30
+
31
+ result = client.exchange_launch_ticket(
32
+ launch_ticket="...",
33
+ expected_nonce="nonce-from-frame",
34
+ )
35
+ print(result.user.sub)
36
+ ```
37
+
38
+ ## FastAPI helper
39
+
40
+ ```python
41
+ from fastapi import FastAPI
42
+ from zisu_app_sdk import ZisuFastAPIAuth, ZisuOIDCClient
43
+
44
+ app = FastAPI()
45
+ client = ZisuOIDCClient(
46
+ issuer="http://127.0.0.1:3005",
47
+ client_id="essay-helper",
48
+ client_secret="zisu-dev-secret",
49
+ redirect_uri="http://127.0.0.1:8000/auth/zisu/callback",
50
+ )
51
+
52
+ auth = ZisuFastAPIAuth(client, app_session_secret="change-me")
53
+ app.include_router(auth.router())
54
+ ```
55
+
56
+ Set `cookie_secure=True` when the app is served over HTTPS.
57
+
58
+ Routes added by the helper:
59
+
60
+ - `GET /login/zisu`
61
+ - `GET /auth/zisu/callback`
62
+ - `POST /auth/zisu/embed`
63
+ - `GET /api/me`
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/zisu_app_sdk/__init__.py
4
+ src/zisu_app_sdk/client.py
5
+ src/zisu_app_sdk/errors.py
6
+ src/zisu_app_sdk/fastapi.py
7
+ src/zisu_app_sdk/http.py
8
+ src/zisu_app_sdk/jwt.py
9
+ src/zisu_app_sdk/models.py
10
+ src/zisu_app_sdk.egg-info/PKG-INFO
11
+ src/zisu_app_sdk.egg-info/SOURCES.txt
12
+ src/zisu_app_sdk.egg-info/dependency_links.txt
13
+ src/zisu_app_sdk.egg-info/requires.txt
14
+ src/zisu_app_sdk.egg-info/top_level.txt
15
+ tests/test_client.py
16
+ tests/test_jwt.py
@@ -0,0 +1,4 @@
1
+
2
+ [fastapi]
3
+ fastapi>=0.100
4
+ starlette>=0.27
@@ -0,0 +1 @@
1
+ zisu_app_sdk
@@ -0,0 +1,87 @@
1
+ import time
2
+ import unittest
3
+ from urllib import parse
4
+
5
+ from test_jwt import jwks, signed_jwt
6
+ from zisu_app_sdk import ZisuOIDCClient
7
+
8
+
9
+ class FakeHTTP:
10
+ def __init__(self):
11
+ self.forms = []
12
+ self.discovery = {
13
+ "issuer": "http://127.0.0.1:3005",
14
+ "authorization_endpoint": "http://127.0.0.1:3005/oauth/authorize",
15
+ "token_endpoint": "http://127.0.0.1:3005/oauth/token",
16
+ "userinfo_endpoint": "http://127.0.0.1:3005/oauth/userinfo",
17
+ "jwks_uri": "http://127.0.0.1:3005/oauth/jwks",
18
+ }
19
+
20
+ def get_json(self, url, headers=None):
21
+ if url.endswith("/.well-known/openid-configuration"):
22
+ return self.discovery
23
+ if url.endswith("/oauth/jwks"):
24
+ return jwks()
25
+ if url.endswith("/oauth/userinfo"):
26
+ return {"sub": "123", "name": "Ada", "email": "ada@example.com"}
27
+ raise AssertionError(f"unexpected get {url}")
28
+
29
+ def post_form(self, url, data, headers=None):
30
+ self.forms.append((url, dict(data)))
31
+ claims = {
32
+ "iss": "http://127.0.0.1:3005",
33
+ "aud": "essay-helper",
34
+ "sub": "123",
35
+ "exp": int(time.time()) + 600,
36
+ "nonce": "frame-nonce",
37
+ "name": "Ada",
38
+ }
39
+ return {
40
+ "access_token": "app-access-token",
41
+ "id_token": signed_jwt(claims),
42
+ "token_type": "Bearer",
43
+ "expires_in": 7200,
44
+ "scope": "openid profile email",
45
+ }
46
+
47
+
48
+ class ClientTests(unittest.TestCase):
49
+ def test_authorization_url_contains_required_oidc_params(self):
50
+ http = FakeHTTP()
51
+ client = ZisuOIDCClient(
52
+ issuer="http://127.0.0.1:3005",
53
+ client_id="essay-helper",
54
+ client_secret="secret",
55
+ redirect_uri="http://127.0.0.1:8000/auth/zisu/callback",
56
+ http_client=http,
57
+ )
58
+
59
+ auth = client.get_authorization_url(state="state-1", nonce="nonce-1")
60
+ parsed = parse.urlparse(auth.url)
61
+ query = parse.parse_qs(parsed.query)
62
+
63
+ self.assertEqual(parsed.path, "/oauth/authorize")
64
+ self.assertEqual(query["response_type"], ["code"])
65
+ self.assertEqual(query["client_id"], ["essay-helper"])
66
+ self.assertEqual(query["state"], ["state-1"])
67
+ self.assertEqual(query["nonce"], ["nonce-1"])
68
+
69
+ def test_exchange_launch_ticket_uses_custom_grant_and_returns_user(self):
70
+ http = FakeHTTP()
71
+ client = ZisuOIDCClient(
72
+ issuer="http://127.0.0.1:3005",
73
+ client_id="essay-helper",
74
+ client_secret="secret",
75
+ http_client=http,
76
+ )
77
+
78
+ result = client.exchange_launch_ticket("ticket-1", expected_nonce="frame-nonce")
79
+
80
+ self.assertEqual(result.user.sub, "123")
81
+ self.assertEqual(result.user.email, "ada@example.com")
82
+ self.assertEqual(http.forms[0][1]["grant_type"], "urn:zisu:oauth:grant-type:launch_ticket")
83
+ self.assertEqual(http.forms[0][1]["launch_ticket"], "ticket-1")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ unittest.main()
@@ -0,0 +1,153 @@
1
+ import hashlib
2
+ import json
3
+ import math
4
+ import random
5
+ import time
6
+ import unittest
7
+
8
+ from zisu_app_sdk.errors import ZisuTokenError
9
+ from zisu_app_sdk.jwt import b64url_encode, sign_session, verify_id_token, verify_session
10
+
11
+ RSA_KEY = None
12
+
13
+
14
+ def rsa_key():
15
+ global RSA_KEY
16
+ if RSA_KEY is not None:
17
+ return RSA_KEY
18
+
19
+ rng = random.Random(20260428)
20
+ e = 65537
21
+ while True:
22
+ p = _prime(rng, 512)
23
+ q = _prime(rng, 512)
24
+ if p == q:
25
+ continue
26
+ phi = (p - 1) * (q - 1)
27
+ if math.gcd(e, phi) == 1:
28
+ break
29
+ n = p * q
30
+ d = pow(e, -1, phi)
31
+ RSA_KEY = {"n": n, "e": e, "d": d}
32
+ return RSA_KEY
33
+
34
+
35
+ def signed_jwt(claims, kid="test-key"):
36
+ key = rsa_key()
37
+ header = {"alg": "RS256", "kid": kid, "typ": "JWT"}
38
+ header_segment = b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
39
+ payload_segment = b64url_encode(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
40
+ signed = f"{header_segment}.{payload_segment}".encode("ascii")
41
+
42
+ digest = hashlib.sha256(signed).digest()
43
+ suffix = bytes.fromhex("3031300d060960864801650304020105000420") + digest
44
+ key_size = (key["n"].bit_length() + 7) // 8
45
+ padded = b"\x00\x01" + (b"\xff" * (key_size - len(suffix) - 3)) + b"\x00" + suffix
46
+ signature = pow(int.from_bytes(padded, "big"), key["d"], key["n"]).to_bytes(key_size, "big")
47
+ return f"{header_segment}.{payload_segment}.{b64url_encode(signature)}"
48
+
49
+
50
+ def jwks(kid="test-key"):
51
+ key = rsa_key()
52
+ return {
53
+ "keys": [
54
+ {
55
+ "kty": "RSA",
56
+ "kid": kid,
57
+ "alg": "RS256",
58
+ "use": "sig",
59
+ "n": b64url_encode(key["n"].to_bytes((key["n"].bit_length() + 7) // 8, "big")),
60
+ "e": b64url_encode(key["e"].to_bytes((key["e"].bit_length() + 7) // 8, "big")),
61
+ }
62
+ ]
63
+ }
64
+
65
+
66
+ class JWTTests(unittest.TestCase):
67
+ def test_verify_id_token(self):
68
+ claims = {
69
+ "iss": "http://127.0.0.1:3005",
70
+ "aud": ["essay-helper"],
71
+ "sub": "123",
72
+ "iat": int(time.time()),
73
+ "exp": int(time.time()) + 600,
74
+ "nonce": "nonce-1",
75
+ }
76
+ token = signed_jwt(claims)
77
+
78
+ verified = verify_id_token(
79
+ token,
80
+ jwks=jwks(),
81
+ issuer="http://127.0.0.1:3005",
82
+ audience="essay-helper",
83
+ nonce="nonce-1",
84
+ )
85
+
86
+ self.assertEqual(verified["sub"], "123")
87
+
88
+ def test_verify_id_token_rejects_wrong_nonce(self):
89
+ claims = {
90
+ "iss": "http://127.0.0.1:3005",
91
+ "aud": "essay-helper",
92
+ "sub": "123",
93
+ "exp": int(time.time()) + 600,
94
+ "nonce": "nonce-1",
95
+ }
96
+ token = signed_jwt(claims)
97
+
98
+ with self.assertRaises(ZisuTokenError):
99
+ verify_id_token(
100
+ token,
101
+ jwks=jwks(),
102
+ issuer="http://127.0.0.1:3005",
103
+ audience="essay-helper",
104
+ nonce="wrong",
105
+ )
106
+
107
+ def test_session_token_roundtrip(self):
108
+ token = sign_session({"sub": "123"}, "secret", 60)
109
+ claims = verify_session(token, "secret")
110
+ self.assertEqual(claims["sub"], "123")
111
+
112
+
113
+ def _prime(rng, bits):
114
+ while True:
115
+ candidate = rng.getrandbits(bits)
116
+ candidate |= (1 << (bits - 1)) | 1
117
+ if _is_probable_prime(candidate):
118
+ return candidate
119
+
120
+
121
+ def _is_probable_prime(n):
122
+ small_primes = [3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]
123
+ if n < 2:
124
+ return False
125
+ for p in small_primes:
126
+ if n == p:
127
+ return True
128
+ if n % p == 0:
129
+ return False
130
+
131
+ d = n - 1
132
+ r = 0
133
+ while d % 2 == 0:
134
+ d //= 2
135
+ r += 1
136
+
137
+ for a in [2, 3, 5, 7, 11, 13, 17]:
138
+ if a >= n:
139
+ continue
140
+ x = pow(a, d, n)
141
+ if x == 1 or x == n - 1:
142
+ continue
143
+ for _ in range(r - 1):
144
+ x = pow(x, 2, n)
145
+ if x == n - 1:
146
+ break
147
+ else:
148
+ return False
149
+ return True
150
+
151
+
152
+ if __name__ == "__main__":
153
+ unittest.main()