leap-oauth-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.
- leap_oauth_sdk-0.1.0/.gitignore +11 -0
- leap_oauth_sdk-0.1.0/CHANGELOG.md +12 -0
- leap_oauth_sdk-0.1.0/LICENSE +21 -0
- leap_oauth_sdk-0.1.0/PKG-INFO +241 -0
- leap_oauth_sdk-0.1.0/README.md +186 -0
- leap_oauth_sdk-0.1.0/pyproject.toml +68 -0
- leap_oauth_sdk-0.1.0/src/leap_oauth_sdk/__init__.py +54 -0
- leap_oauth_sdk-0.1.0/src/leap_oauth_sdk/browser.py +400 -0
- leap_oauth_sdk-0.1.0/src/leap_oauth_sdk/config.py +108 -0
- leap_oauth_sdk-0.1.0/src/leap_oauth_sdk/cookie_session.py +330 -0
- leap_oauth_sdk-0.1.0/src/leap_oauth_sdk/errors.py +57 -0
- leap_oauth_sdk-0.1.0/src/leap_oauth_sdk/py.typed +0 -0
- leap_oauth_sdk-0.1.0/src/leap_oauth_sdk/static/leap-sso.js +369 -0
- leap_oauth_sdk-0.1.0/src/leap_oauth_sdk/tokens.py +331 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- `LeapAuthConfig` with explicit issuer, audience, JWKS URL and verification policy.
|
|
6
|
+
- `AccessTokenVerifier` with blocking and async verification sharing one JWKS cache.
|
|
7
|
+
- Error categories `Retryable`, `ReauthRequired`, `Forbidden`, `InvalidResponse`.
|
|
8
|
+
- `BrowserSSO`: code exchange, refresh, online session check and logout for one browser client,
|
|
9
|
+
blocking and async; upstream responses classified into the four error categories.
|
|
10
|
+
- `CookieCipher` and `CookieSessionManager`: encrypted HttpOnly cookie sessions with in-process
|
|
11
|
+
single-flight refresh, secret rotation and one-time migration of an old cookie format.
|
|
12
|
+
- `leap-sso.js`: browser module for automatic SSO, deep links, refresh, duplicated tabs and logout.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Llugaes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: leap-oauth-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Business-side SDK for services that accept Leap Auth OAuth access tokens
|
|
5
|
+
Project-URL: Homepage, https://github.com/Llugaes/leap-oauth-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/Llugaes/leap-oauth-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/Llugaes/leap-oauth-sdk/issues
|
|
8
|
+
Author-email: Llugaes <249094896@qq.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Llugaes
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: access-token,jwks,jwt,oauth,sso
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
41
|
+
Classifier: Topic :: Security
|
|
42
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
43
|
+
Classifier: Typing :: Typed
|
|
44
|
+
Requires-Python: >=3.12
|
|
45
|
+
Requires-Dist: httpx<1,>=0.27
|
|
46
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.10
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: pyright; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
51
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
52
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
53
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
54
|
+
Description-Content-Type: text/markdown
|
|
55
|
+
|
|
56
|
+
# leap-oauth-sdk
|
|
57
|
+
|
|
58
|
+
Business-side SDK for services that accept access tokens issued by Leap Auth, an OAuth 2.1
|
|
59
|
+
authorization server. It verifies tokens locally against the server's JWKS and reports every
|
|
60
|
+
failure as one of four error categories, so each service handles retries, re-login and
|
|
61
|
+
permission errors the same way.
|
|
62
|
+
|
|
63
|
+
Status: alpha. This release contains access token verification, the server side of browser
|
|
64
|
+
sign-in (code exchange, refresh, online session check, logout), encrypted cookie sessions and
|
|
65
|
+
`leap-sso.js`, the matching browser module. Directory lookup and member management are planned.
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
uv add leap-oauth-sdk
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Python 3.12 or newer.
|
|
74
|
+
|
|
75
|
+
## Verify an access token
|
|
76
|
+
|
|
77
|
+
Create one verifier per process and reuse it, so the JWKS cache is shared by all requests.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from leap_oauth_sdk import AccessTokenVerifier, LeapAuthConfig, OAuthSDKError
|
|
81
|
+
|
|
82
|
+
verifier = AccessTokenVerifier(
|
|
83
|
+
LeapAuthConfig(
|
|
84
|
+
issuer="https://auth.example.com", # must equal the token's iss claim exactly
|
|
85
|
+
audience="example-service", # this service's own audience
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def current_subject(bearer_token: str) -> str:
|
|
90
|
+
try:
|
|
91
|
+
context = await verifier.verify_async(bearer_token)
|
|
92
|
+
except OAuthSDKError as exc:
|
|
93
|
+
# exc.status is the suggested local HTTP status; exc.code is stable for logs.
|
|
94
|
+
raise HTTPException(status_code=exc.status, detail={"error": exc.code})
|
|
95
|
+
return context.subject_id
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`verify()` is the blocking variant. Both share the same cache.
|
|
99
|
+
|
|
100
|
+
The SDK does not read environment variables and has no default hosts. Load settings in your
|
|
101
|
+
own configuration layer and pass them to `LeapAuthConfig`.
|
|
102
|
+
|
|
103
|
+
## Browser sign-in
|
|
104
|
+
|
|
105
|
+
One browser client is registered per service and environment. The browser runs authorization
|
|
106
|
+
code + PKCE with a top-level redirect; your backend exchanges the code. Tokens never appear in
|
|
107
|
+
URLs. Two ways to hold credentials:
|
|
108
|
+
|
|
109
|
+
- **bearer**: the page keeps the access and refresh token in `sessionStorage` and sends
|
|
110
|
+
`Authorization: Bearer`. Your API verifies with `check_session()`.
|
|
111
|
+
- **cookie**: the backend keeps both tokens in an encrypted HttpOnly cookie and the page never
|
|
112
|
+
sees them. Use `CookieSessionManager`.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from leap_oauth_sdk import BrowserClientConfig, BrowserSSO, LeapAuthConfig, OAuthSDKError
|
|
116
|
+
|
|
117
|
+
sso = BrowserSSO(
|
|
118
|
+
LeapAuthConfig(issuer="https://auth.example.com", audience="example-service"),
|
|
119
|
+
BrowserClientConfig(
|
|
120
|
+
client_id="web-example",
|
|
121
|
+
redirect_uri="https://app.example.com/auth/callback", # exact registered callback
|
|
122
|
+
resource="https://app.example.com/",
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Serve these routes under one base path (bearer mode shown). On `OAuthSDKError` every route
|
|
128
|
+
responds with `exc.status`, `exc.headers` and the body `exc.to_dict()`:
|
|
129
|
+
|
|
130
|
+
| Route | Handler |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| `GET {base}/config` | `sso.browser_config()` |
|
|
133
|
+
| `POST {base}/exchange` `{code, code_verifier}` | `sso.exchange_code(...).to_browser_payload()` |
|
|
134
|
+
| `POST {base}/refresh` `{refresh_token}` | `sso.refresh(...).to_browser_payload()` |
|
|
135
|
+
| `POST {base}/logout` `{scope}` + Bearer | `sso.logout(token, scope)` |
|
|
136
|
+
|
|
137
|
+
Protected API routes call `sso.check_session(bearer_token)` on every request and must not
|
|
138
|
+
cache the result, so that signing out in one service takes effect everywhere on the next
|
|
139
|
+
request. An already-ended sign-in counts as a successful logout.
|
|
140
|
+
|
|
141
|
+
### Browser module
|
|
142
|
+
|
|
143
|
+
`leap-sso.js` ships inside the package; `leap_oauth_sdk.static_dir()` returns its directory.
|
|
144
|
+
|
|
145
|
+
```html
|
|
146
|
+
<script src="/static/leap-sso.js"></script>
|
|
147
|
+
<script>
|
|
148
|
+
const sso = LeapSSO.create({
|
|
149
|
+
endpointBase: "/api/auth/browser",
|
|
150
|
+
credentialMode: "bearer", // or "cookie"
|
|
151
|
+
storagePrefix: "example.admin", // sessionStorage keys; storageKeys can keep old names
|
|
152
|
+
defaultNext: "/admin",
|
|
153
|
+
hooks: {
|
|
154
|
+
onLoading: () => showSpinner(),
|
|
155
|
+
onLogin: (message, retryable) => showSignIn(message, retryable),
|
|
156
|
+
onForbidden: () => showNoAccess(),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
const state = await sso.boot(); // "ready" | "redirecting" | "login" | "forbidden" | "disabled"
|
|
160
|
+
if (state === "ready") render(await (await sso.fetch("/api/me")).json());
|
|
161
|
+
signInButton.onclick = () => sso.login();
|
|
162
|
+
signOutButton.onclick = () => sso.logout().catch(showRetry);
|
|
163
|
+
</script>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- `boot()` finishes a callback, restores the deep link, or starts one automatic sign-in that
|
|
167
|
+
only reuses an existing sign-in. Every page load makes at most one automatic attempt; a
|
|
168
|
+
failed callback shows sign-in instead of looping.
|
|
169
|
+
- `fetch()` adds the bearer token and refreshes 60 seconds before expiry, one refresh at a time.
|
|
170
|
+
A 401 drops local credentials and makes the one automatic attempt; 403 and other statuses
|
|
171
|
+
are returned to you untouched.
|
|
172
|
+
- A duplicated tab copies `sessionStorage` and with it a refresh token the other tab may
|
|
173
|
+
already have spent. The server then answers `refresh_rotation_in_progress`; the module drops
|
|
174
|
+
the copy and signs in again through SSO instead of replaying it. In cookie mode the same
|
|
175
|
+
answer comes from two replicas refreshing at once, so the request is retried briefly.
|
|
176
|
+
- `logout()` sends `scope=browser`, which ends the sign-in for every service in this browser.
|
|
177
|
+
Credentials are cleared only when the backend confirms; the next page load shows sign-in once.
|
|
178
|
+
- Messages default to Chinese; pass `messages` to override.
|
|
179
|
+
|
|
180
|
+
### Cookie sessions
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from leap_oauth_sdk import CookieCipher, CookieSessionManager
|
|
184
|
+
|
|
185
|
+
cookies = CookieSessionManager(sso, CookieCipher(secret)) # secret: 32+ characters
|
|
186
|
+
|
|
187
|
+
# callback: value = cookies.issue(sso.exchange_code(code, verifier), extra={"csrf": csrf})
|
|
188
|
+
# per request:
|
|
189
|
+
result = cookies.resolve(request.cookies.get("example_session"))
|
|
190
|
+
if result.cookie is not None: # refreshed or migrated: write it back
|
|
191
|
+
response.set_cookie(
|
|
192
|
+
"example_session", result.cookie, max_age=cookies.max_age(result.session),
|
|
193
|
+
httponly=True, secure=True, samesite="lax",
|
|
194
|
+
)
|
|
195
|
+
# logout: cookies.logout(value), then delete the cookie
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`resolve()` refreshes shortly before expiry and checks the sign-in online. Requests carrying
|
|
199
|
+
the same old cookie share one refresh within the process, and the result is reused for ten
|
|
200
|
+
seconds so a late request never replays the spent refresh token. `CookieCipher` accepts
|
|
201
|
+
`previous_secrets` for rotation and a `context` string; `legacy_loader` lets a service read its
|
|
202
|
+
old cookie format once and rewrite it.
|
|
203
|
+
|
|
204
|
+
## Verification rules
|
|
205
|
+
|
|
206
|
+
- Signature algorithm must be in `allowed_algorithms` (default `EdDSA` only); the token must
|
|
207
|
+
carry a `kid` that exists in the JWKS.
|
|
208
|
+
- Registered claims `exp`, `iat`, `nbf`, `iss`, `sub`, `aud`, `jti` are required; `aud` must be a
|
|
209
|
+
single string equal to `audience`; `iss` must equal `issuer`.
|
|
210
|
+
- `ver` must equal `token_version`; `status` must be `active`; `source.channel` must be present;
|
|
211
|
+
`purpose` must be in `allowed_purposes` (a token without `purpose` counts as `access`).
|
|
212
|
+
- The JWKS is cached for `jwks_cache_seconds` (300). An unknown `kid` forces at most one refetch
|
|
213
|
+
per `jwks_refresh_cooldown_seconds` (10). A failed or malformed fetch is never cached and never
|
|
214
|
+
falls back to expired keys. Redirects are not followed.
|
|
215
|
+
|
|
216
|
+
Business authorization (roles, resources, fields, actions) stays in your service. A verified
|
|
217
|
+
token only proves who the caller is.
|
|
218
|
+
|
|
219
|
+
## Error categories
|
|
220
|
+
|
|
221
|
+
| Exception | Status | Meaning | What to do |
|
|
222
|
+
| --- | --- | --- | --- |
|
|
223
|
+
| `Retryable` | 503 | Authorization server or JWKS unavailable, timeout, 5xx or 429, `temporarily_unavailable` (including `refresh_rotation_in_progress`) | Keep credentials, retry later |
|
|
224
|
+
| `ReauthRequired` | 401 | Token invalid, expired, revoked or unknown key; upstream 401 or `invalid_grant`; sign-in no longer active | Clear credentials, sign in again |
|
|
225
|
+
| `Forbidden` | 403 | Principal inactive, token purpose not accepted, upstream 403 | Show a permission error, do not re-login |
|
|
226
|
+
| `InvalidResponse` | 502 | Malformed upstream response, redirect, `invalid_client`, token not bound to a browser sign-in | Alert; do not loop sign-in |
|
|
227
|
+
|
|
228
|
+
All four inherit from `OAuthSDKError` and expose `code`, `message`, `status` and `retryable`;
|
|
229
|
+
`to_dict()` and `headers` (`Retry-After` when retryable) build your error response. Messages
|
|
230
|
+
never include tokens or upstream response bodies.
|
|
231
|
+
|
|
232
|
+
## Development
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
uv sync --extra dev
|
|
236
|
+
bash scripts/check.sh # also runs the browser module tests (Node 20+)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# leap-oauth-sdk
|
|
2
|
+
|
|
3
|
+
Business-side SDK for services that accept access tokens issued by Leap Auth, an OAuth 2.1
|
|
4
|
+
authorization server. It verifies tokens locally against the server's JWKS and reports every
|
|
5
|
+
failure as one of four error categories, so each service handles retries, re-login and
|
|
6
|
+
permission errors the same way.
|
|
7
|
+
|
|
8
|
+
Status: alpha. This release contains access token verification, the server side of browser
|
|
9
|
+
sign-in (code exchange, refresh, online session check, logout), encrypted cookie sessions and
|
|
10
|
+
`leap-sso.js`, the matching browser module. Directory lookup and member management are planned.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv add leap-oauth-sdk
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Python 3.12 or newer.
|
|
19
|
+
|
|
20
|
+
## Verify an access token
|
|
21
|
+
|
|
22
|
+
Create one verifier per process and reuse it, so the JWKS cache is shared by all requests.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from leap_oauth_sdk import AccessTokenVerifier, LeapAuthConfig, OAuthSDKError
|
|
26
|
+
|
|
27
|
+
verifier = AccessTokenVerifier(
|
|
28
|
+
LeapAuthConfig(
|
|
29
|
+
issuer="https://auth.example.com", # must equal the token's iss claim exactly
|
|
30
|
+
audience="example-service", # this service's own audience
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def current_subject(bearer_token: str) -> str:
|
|
35
|
+
try:
|
|
36
|
+
context = await verifier.verify_async(bearer_token)
|
|
37
|
+
except OAuthSDKError as exc:
|
|
38
|
+
# exc.status is the suggested local HTTP status; exc.code is stable for logs.
|
|
39
|
+
raise HTTPException(status_code=exc.status, detail={"error": exc.code})
|
|
40
|
+
return context.subject_id
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`verify()` is the blocking variant. Both share the same cache.
|
|
44
|
+
|
|
45
|
+
The SDK does not read environment variables and has no default hosts. Load settings in your
|
|
46
|
+
own configuration layer and pass them to `LeapAuthConfig`.
|
|
47
|
+
|
|
48
|
+
## Browser sign-in
|
|
49
|
+
|
|
50
|
+
One browser client is registered per service and environment. The browser runs authorization
|
|
51
|
+
code + PKCE with a top-level redirect; your backend exchanges the code. Tokens never appear in
|
|
52
|
+
URLs. Two ways to hold credentials:
|
|
53
|
+
|
|
54
|
+
- **bearer**: the page keeps the access and refresh token in `sessionStorage` and sends
|
|
55
|
+
`Authorization: Bearer`. Your API verifies with `check_session()`.
|
|
56
|
+
- **cookie**: the backend keeps both tokens in an encrypted HttpOnly cookie and the page never
|
|
57
|
+
sees them. Use `CookieSessionManager`.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from leap_oauth_sdk import BrowserClientConfig, BrowserSSO, LeapAuthConfig, OAuthSDKError
|
|
61
|
+
|
|
62
|
+
sso = BrowserSSO(
|
|
63
|
+
LeapAuthConfig(issuer="https://auth.example.com", audience="example-service"),
|
|
64
|
+
BrowserClientConfig(
|
|
65
|
+
client_id="web-example",
|
|
66
|
+
redirect_uri="https://app.example.com/auth/callback", # exact registered callback
|
|
67
|
+
resource="https://app.example.com/",
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Serve these routes under one base path (bearer mode shown). On `OAuthSDKError` every route
|
|
73
|
+
responds with `exc.status`, `exc.headers` and the body `exc.to_dict()`:
|
|
74
|
+
|
|
75
|
+
| Route | Handler |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `GET {base}/config` | `sso.browser_config()` |
|
|
78
|
+
| `POST {base}/exchange` `{code, code_verifier}` | `sso.exchange_code(...).to_browser_payload()` |
|
|
79
|
+
| `POST {base}/refresh` `{refresh_token}` | `sso.refresh(...).to_browser_payload()` |
|
|
80
|
+
| `POST {base}/logout` `{scope}` + Bearer | `sso.logout(token, scope)` |
|
|
81
|
+
|
|
82
|
+
Protected API routes call `sso.check_session(bearer_token)` on every request and must not
|
|
83
|
+
cache the result, so that signing out in one service takes effect everywhere on the next
|
|
84
|
+
request. An already-ended sign-in counts as a successful logout.
|
|
85
|
+
|
|
86
|
+
### Browser module
|
|
87
|
+
|
|
88
|
+
`leap-sso.js` ships inside the package; `leap_oauth_sdk.static_dir()` returns its directory.
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<script src="/static/leap-sso.js"></script>
|
|
92
|
+
<script>
|
|
93
|
+
const sso = LeapSSO.create({
|
|
94
|
+
endpointBase: "/api/auth/browser",
|
|
95
|
+
credentialMode: "bearer", // or "cookie"
|
|
96
|
+
storagePrefix: "example.admin", // sessionStorage keys; storageKeys can keep old names
|
|
97
|
+
defaultNext: "/admin",
|
|
98
|
+
hooks: {
|
|
99
|
+
onLoading: () => showSpinner(),
|
|
100
|
+
onLogin: (message, retryable) => showSignIn(message, retryable),
|
|
101
|
+
onForbidden: () => showNoAccess(),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const state = await sso.boot(); // "ready" | "redirecting" | "login" | "forbidden" | "disabled"
|
|
105
|
+
if (state === "ready") render(await (await sso.fetch("/api/me")).json());
|
|
106
|
+
signInButton.onclick = () => sso.login();
|
|
107
|
+
signOutButton.onclick = () => sso.logout().catch(showRetry);
|
|
108
|
+
</script>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- `boot()` finishes a callback, restores the deep link, or starts one automatic sign-in that
|
|
112
|
+
only reuses an existing sign-in. Every page load makes at most one automatic attempt; a
|
|
113
|
+
failed callback shows sign-in instead of looping.
|
|
114
|
+
- `fetch()` adds the bearer token and refreshes 60 seconds before expiry, one refresh at a time.
|
|
115
|
+
A 401 drops local credentials and makes the one automatic attempt; 403 and other statuses
|
|
116
|
+
are returned to you untouched.
|
|
117
|
+
- A duplicated tab copies `sessionStorage` and with it a refresh token the other tab may
|
|
118
|
+
already have spent. The server then answers `refresh_rotation_in_progress`; the module drops
|
|
119
|
+
the copy and signs in again through SSO instead of replaying it. In cookie mode the same
|
|
120
|
+
answer comes from two replicas refreshing at once, so the request is retried briefly.
|
|
121
|
+
- `logout()` sends `scope=browser`, which ends the sign-in for every service in this browser.
|
|
122
|
+
Credentials are cleared only when the backend confirms; the next page load shows sign-in once.
|
|
123
|
+
- Messages default to Chinese; pass `messages` to override.
|
|
124
|
+
|
|
125
|
+
### Cookie sessions
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from leap_oauth_sdk import CookieCipher, CookieSessionManager
|
|
129
|
+
|
|
130
|
+
cookies = CookieSessionManager(sso, CookieCipher(secret)) # secret: 32+ characters
|
|
131
|
+
|
|
132
|
+
# callback: value = cookies.issue(sso.exchange_code(code, verifier), extra={"csrf": csrf})
|
|
133
|
+
# per request:
|
|
134
|
+
result = cookies.resolve(request.cookies.get("example_session"))
|
|
135
|
+
if result.cookie is not None: # refreshed or migrated: write it back
|
|
136
|
+
response.set_cookie(
|
|
137
|
+
"example_session", result.cookie, max_age=cookies.max_age(result.session),
|
|
138
|
+
httponly=True, secure=True, samesite="lax",
|
|
139
|
+
)
|
|
140
|
+
# logout: cookies.logout(value), then delete the cookie
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`resolve()` refreshes shortly before expiry and checks the sign-in online. Requests carrying
|
|
144
|
+
the same old cookie share one refresh within the process, and the result is reused for ten
|
|
145
|
+
seconds so a late request never replays the spent refresh token. `CookieCipher` accepts
|
|
146
|
+
`previous_secrets` for rotation and a `context` string; `legacy_loader` lets a service read its
|
|
147
|
+
old cookie format once and rewrite it.
|
|
148
|
+
|
|
149
|
+
## Verification rules
|
|
150
|
+
|
|
151
|
+
- Signature algorithm must be in `allowed_algorithms` (default `EdDSA` only); the token must
|
|
152
|
+
carry a `kid` that exists in the JWKS.
|
|
153
|
+
- Registered claims `exp`, `iat`, `nbf`, `iss`, `sub`, `aud`, `jti` are required; `aud` must be a
|
|
154
|
+
single string equal to `audience`; `iss` must equal `issuer`.
|
|
155
|
+
- `ver` must equal `token_version`; `status` must be `active`; `source.channel` must be present;
|
|
156
|
+
`purpose` must be in `allowed_purposes` (a token without `purpose` counts as `access`).
|
|
157
|
+
- The JWKS is cached for `jwks_cache_seconds` (300). An unknown `kid` forces at most one refetch
|
|
158
|
+
per `jwks_refresh_cooldown_seconds` (10). A failed or malformed fetch is never cached and never
|
|
159
|
+
falls back to expired keys. Redirects are not followed.
|
|
160
|
+
|
|
161
|
+
Business authorization (roles, resources, fields, actions) stays in your service. A verified
|
|
162
|
+
token only proves who the caller is.
|
|
163
|
+
|
|
164
|
+
## Error categories
|
|
165
|
+
|
|
166
|
+
| Exception | Status | Meaning | What to do |
|
|
167
|
+
| --- | --- | --- | --- |
|
|
168
|
+
| `Retryable` | 503 | Authorization server or JWKS unavailable, timeout, 5xx or 429, `temporarily_unavailable` (including `refresh_rotation_in_progress`) | Keep credentials, retry later |
|
|
169
|
+
| `ReauthRequired` | 401 | Token invalid, expired, revoked or unknown key; upstream 401 or `invalid_grant`; sign-in no longer active | Clear credentials, sign in again |
|
|
170
|
+
| `Forbidden` | 403 | Principal inactive, token purpose not accepted, upstream 403 | Show a permission error, do not re-login |
|
|
171
|
+
| `InvalidResponse` | 502 | Malformed upstream response, redirect, `invalid_client`, token not bound to a browser sign-in | Alert; do not loop sign-in |
|
|
172
|
+
|
|
173
|
+
All four inherit from `OAuthSDKError` and expose `code`, `message`, `status` and `retryable`;
|
|
174
|
+
`to_dict()` and `headers` (`Retry-After` when retryable) build your error response. Messages
|
|
175
|
+
never include tokens or upstream response bodies.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
uv sync --extra dev
|
|
181
|
+
bash scripts/check.sh # also runs the browser module tests (Node 20+)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "leap-oauth-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
description = "Business-side SDK for services that accept Leap Auth OAuth access tokens"
|
|
6
|
+
authors = [{name = "Llugaes", email = "249094896@qq.com"}]
|
|
7
|
+
license = {file = "LICENSE"}
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
keywords = ["oauth", "jwt", "jwks", "access-token", "sso"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.27,<1",
|
|
26
|
+
"pyjwt[crypto]>=2.10,<3",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/Llugaes/leap-oauth-sdk"
|
|
31
|
+
Repository = "https://github.com/Llugaes/leap-oauth-sdk"
|
|
32
|
+
Issues = "https://github.com/Llugaes/leap-oauth-sdk/issues"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8",
|
|
37
|
+
"pytest-asyncio>=0.23",
|
|
38
|
+
"pytest-cov",
|
|
39
|
+
"respx>=0.21",
|
|
40
|
+
"pyright",
|
|
41
|
+
"ruff",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["hatchling"]
|
|
46
|
+
build-backend = "hatchling.build"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/leap_oauth_sdk"]
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.sdist]
|
|
52
|
+
only-include = ["src/leap_oauth_sdk", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
|
|
58
|
+
[tool.pyright]
|
|
59
|
+
include = ["src"]
|
|
60
|
+
strict = ["src"]
|
|
61
|
+
pythonVersion = "3.12"
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
target-version = "py312"
|
|
65
|
+
line-length = 100
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
select = ["E", "F", "W", "I", "B", "UP", "ASYNC"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Business-side SDK for services that accept Leap Auth OAuth access tokens."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .browser import (
|
|
6
|
+
ROTATION_IN_PROGRESS,
|
|
7
|
+
AuthorizationRequest,
|
|
8
|
+
BrowserSession,
|
|
9
|
+
BrowserSSO,
|
|
10
|
+
LogoutScope,
|
|
11
|
+
classify_response,
|
|
12
|
+
)
|
|
13
|
+
from .config import DEFAULT_TOKEN_VERSION, BrowserClientConfig, LeapAuthConfig
|
|
14
|
+
from .cookie_session import (
|
|
15
|
+
CookieCipher,
|
|
16
|
+
CookieResolution,
|
|
17
|
+
CookieSession,
|
|
18
|
+
CookieSessionManager,
|
|
19
|
+
)
|
|
20
|
+
from .errors import Forbidden, InvalidResponse, OAuthSDKError, ReauthRequired, Retryable
|
|
21
|
+
from .tokens import AccessTokenContext, AccessTokenVerifier
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def static_dir() -> Path:
|
|
27
|
+
"""Directory holding ``leap-sso.js``; mount it as static files in your web framework."""
|
|
28
|
+
return Path(__file__).with_name("static")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_TOKEN_VERSION",
|
|
33
|
+
"ROTATION_IN_PROGRESS",
|
|
34
|
+
"AccessTokenContext",
|
|
35
|
+
"AccessTokenVerifier",
|
|
36
|
+
"AuthorizationRequest",
|
|
37
|
+
"BrowserClientConfig",
|
|
38
|
+
"BrowserSSO",
|
|
39
|
+
"BrowserSession",
|
|
40
|
+
"CookieCipher",
|
|
41
|
+
"CookieResolution",
|
|
42
|
+
"CookieSession",
|
|
43
|
+
"CookieSessionManager",
|
|
44
|
+
"Forbidden",
|
|
45
|
+
"InvalidResponse",
|
|
46
|
+
"LeapAuthConfig",
|
|
47
|
+
"LogoutScope",
|
|
48
|
+
"OAuthSDKError",
|
|
49
|
+
"ReauthRequired",
|
|
50
|
+
"Retryable",
|
|
51
|
+
"__version__",
|
|
52
|
+
"classify_response",
|
|
53
|
+
"static_dir",
|
|
54
|
+
]
|