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.
- zisu_app_sdk-0.1.0/PKG-INFO +63 -0
- zisu_app_sdk-0.1.0/README.md +52 -0
- zisu_app_sdk-0.1.0/pyproject.toml +24 -0
- zisu_app_sdk-0.1.0/setup.cfg +4 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk/__init__.py +16 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk/client.py +165 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk/errors.py +14 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk/fastapi.py +123 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk/http.py +39 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk/jwt.py +160 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk/models.py +39 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk.egg-info/PKG-INFO +63 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk.egg-info/SOURCES.txt +16 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk.egg-info/dependency_links.txt +1 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk.egg-info/requires.txt +4 -0
- zisu_app_sdk-0.1.0/src/zisu_app_sdk.egg-info/top_level.txt +1 -0
- zisu_app_sdk-0.1.0/tests/test_client.py +87 -0
- zisu_app_sdk-0.1.0/tests/test_jwt.py +153 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|