lowcoai-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lowcoai_auth-0.1.0/.gitignore +17 -0
- lowcoai_auth-0.1.0/LICENSE +21 -0
- lowcoai_auth-0.1.0/PKG-INFO +174 -0
- lowcoai_auth-0.1.0/README.md +152 -0
- lowcoai_auth-0.1.0/pyproject.toml +48 -0
- lowcoai_auth-0.1.0/src/lowcoai/auth/__init__.py +28 -0
- lowcoai_auth-0.1.0/src/lowcoai/auth/_base.py +237 -0
- lowcoai_auth-0.1.0/src/lowcoai/auth/_client.py +250 -0
- lowcoai_auth-0.1.0/src/lowcoai/auth/_errors.py +56 -0
- lowcoai_auth-0.1.0/src/lowcoai/auth/_tokens.py +135 -0
- lowcoai_auth-0.1.0/src/lowcoai/auth/py.typed +0 -0
- lowcoai_auth-0.1.0/src/lowcoai/auth/types.py +105 -0
- lowcoai_auth-0.1.0/tests/test_async_client.py +137 -0
- lowcoai_auth-0.1.0/tests/test_client.py +519 -0
- lowcoai_auth-0.1.0/tests/test_tokens.py +174 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lowco AI
|
|
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,174 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lowcoai-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-agnostic Python helpers for the lowco OAuth 2.0 Authorization Code + PKCE flow.
|
|
5
|
+
Project-URL: Repository, https://github.com/lowcoai/lowco-sdk
|
|
6
|
+
Project-URL: Source, https://github.com/lowcoai/lowco-sdk/tree/main/python/auth
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: auth,lowco,oauth,oauth2,oidc,pkce,sdk
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx<1,>=0.25
|
|
15
|
+
Requires-Dist: typing-extensions>=4.7
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# lowcoai-auth
|
|
24
|
+
|
|
25
|
+
Framework-agnostic Python helpers for the **lowco** OAuth 2.0 Authorization Code + PKCE flow: the server-side counterpart of the React SDK [`@lowcoai/auth`](../../npm/auth), with the same wire behaviour and the same persisted token shape. Works with Flask, Django, FastAPI, Starlette, CLIs with a loopback redirect, and so on. Sync and asyncio clients with the same surface, built on [httpx](https://www.python-httpx.org/). Python 3.10+.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install lowcoai-auth
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start (Flask)
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from flask import Flask, redirect, request, session
|
|
35
|
+
from lowcoai.auth import AuthorizationRequest, LowcoAuthClient, LowcoAuthError, get_user
|
|
36
|
+
|
|
37
|
+
app = Flask(__name__)
|
|
38
|
+
app.secret_key = "..."
|
|
39
|
+
|
|
40
|
+
auth = LowcoAuthClient(
|
|
41
|
+
"https://api.lowco.ai/v1/identity", # domain
|
|
42
|
+
"https://auth.lowco.ai/login", # login_page_url
|
|
43
|
+
"tenant_123",
|
|
44
|
+
"client_abc",
|
|
45
|
+
redirect_uri="https://app.example.com/auth/callback",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.get("/login")
|
|
50
|
+
def login():
|
|
51
|
+
req = auth.create_authorization_request(app_state={"returnTo": request.args.get("next", "/")})
|
|
52
|
+
session["lowco_auth"] = req.to_dict() # holds the PKCE verifier until the callback
|
|
53
|
+
return redirect(req.url)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.get("/auth/callback")
|
|
57
|
+
def callback():
|
|
58
|
+
req = AuthorizationRequest.from_dict(session.pop("lowco_auth"))
|
|
59
|
+
try:
|
|
60
|
+
session["tokens"] = auth.handle_redirect_callback(request.url, req)
|
|
61
|
+
except LowcoAuthError as err:
|
|
62
|
+
return f"Sign-in failed: {err.message}", 400
|
|
63
|
+
return redirect((req.app_state or {}).get("returnTo", "/"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.get("/me")
|
|
67
|
+
def me():
|
|
68
|
+
try:
|
|
69
|
+
tokens = auth.get_valid_tokens(session.get("tokens")) # refreshes when needed
|
|
70
|
+
except LowcoAuthError:
|
|
71
|
+
return redirect("/login")
|
|
72
|
+
session["tokens"] = tokens
|
|
73
|
+
return {"user": get_user(tokens), "access_token_expires_at": tokens["expires_at"]}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### asyncio
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from lowcoai.auth import AsyncLowcoAuthClient
|
|
80
|
+
|
|
81
|
+
async with AsyncLowcoAuthClient(
|
|
82
|
+
domain, login_page_url, tenant_id, client_id, redirect_uri=redirect_uri
|
|
83
|
+
) as auth:
|
|
84
|
+
req = auth.create_authorization_request() # sync: no I/O
|
|
85
|
+
tokens = await auth.handle_redirect_callback(callback_url, req)
|
|
86
|
+
tokens = await auth.get_valid_tokens(tokens)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`AsyncLowcoAuthClient` has the same methods as `LowcoAuthClient`; the network methods are coroutines and `create_authorization_request` stays synchronous. Close it with `await auth.aclose()` if you don't use `async with`.
|
|
90
|
+
|
|
91
|
+
### CLI with a loopback redirect
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
req = auth.create_authorization_request(redirect_uri="http://127.0.0.1:8765/callback")
|
|
95
|
+
webbrowser.open(req.url)
|
|
96
|
+
callback_path = wait_for_one_request_on_port(8765) # e.g. "/callback?code=...&state=..."
|
|
97
|
+
tokens = auth.handle_redirect_callback(callback_path, req)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Options
|
|
101
|
+
|
|
102
|
+
| Argument | Description |
|
|
103
|
+
| ---------------- | ----------------------------------------------------------------------------------------- |
|
|
104
|
+
| `domain` | Auth server base, e.g. `https://api.lowco.ai/v1/identity`. Token endpoint: `{domain}/tenants/{tenant_id}/oauth/token`. |
|
|
105
|
+
| `login_page_url` | Hosted login page the user is sent to. A query string already on it is kept. |
|
|
106
|
+
| `tenant_id` | Tenant identifier. |
|
|
107
|
+
| `client_id` | OAuth client identifier (public client: no secret is sent, PKCE protects the code). |
|
|
108
|
+
| `redirect_uri` | Default callback URL. Required here or per `create_authorization_request` call. |
|
|
109
|
+
| `scope` | Default scope, `"openid profile email offline_access"`. |
|
|
110
|
+
| `timeout` | Per-request timeout in seconds (default `30`). `None` disables it. |
|
|
111
|
+
| `http_client` | Your own `httpx.Client` / `httpx.AsyncClient` (proxies, retries, test transports). It is not closed by the SDK. |
|
|
112
|
+
|
|
113
|
+
The client stores nothing per user, so one instance can be shared by the whole app. `auth.token_endpoint` returns the token URL.
|
|
114
|
+
|
|
115
|
+
## Surface
|
|
116
|
+
|
|
117
|
+
| Call | What it does |
|
|
118
|
+
| ---- | ------------ |
|
|
119
|
+
| `create_authorization_request(*, redirect_uri=None, scope=None, app_state=None)` | Generates `state`, `nonce` (24 random bytes each) and a PKCE `code_verifier` (48 bytes), all base64url without padding, and returns an `AuthorizationRequest` whose `url` is `login_page_url` plus `tenant_id`, `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state`, `nonce`, `code_challenge` (S256) and `code_challenge_method=S256`. No I/O. |
|
|
120
|
+
| `handle_redirect_callback(callback_url, request)` | Reads `code` / `state` (full URL, `/path?query` or bare query), raises `LowcoAuthError` on an OAuth `error`, a missing `code` / `state` or a `state` mismatch (constant-time compare), then calls `exchange_code`. |
|
|
121
|
+
| `exchange_code(code, request)` | `POST` form `grant_type=authorization_code`, `tenant_id`, `client_id`, `code`, `code_verifier`, `redirect_uri`. Returns a `TokenSet`. |
|
|
122
|
+
| `refresh_tokens(tokens_or_refresh_token)` | `POST` form `grant_type=refresh_token`, `tenant_id`, `client_id`, `refresh_token`. Given a `TokenSet`, the response is merged over it (a refresh token the server does not rotate is kept). |
|
|
123
|
+
| `get_valid_tokens(tokens)` | Returns `tokens` unchanged (same object) while the access token is valid, refreshes when there is a refresh token, else raises `LowcoAuthError("Session expired.")` (`"Not authenticated."` without tokens). |
|
|
124
|
+
|
|
125
|
+
Module-level helpers:
|
|
126
|
+
|
|
127
|
+
| Helper | Description |
|
|
128
|
+
| ------ | ----------- |
|
|
129
|
+
| `is_access_token_valid(tokens, skew_seconds=60)` | `expires_at` is more than `skew_seconds` in the future. |
|
|
130
|
+
| `can_recover_session(tokens)` | Access token still valid, or a refresh token is present. |
|
|
131
|
+
| `get_user(tokens)` | Claims of the ID token (else the access token) as a `UserProfile`: `sub`, `email`, `name`, `picture` plus every other claim. |
|
|
132
|
+
|
|
133
|
+
> **`get_user` does not verify the JWT signature.** Use it for display only (showing the user's name, avatar, email). For authorisation decisions, validate the access token on the server that receives it.
|
|
134
|
+
|
|
135
|
+
## Data shapes
|
|
136
|
+
|
|
137
|
+
`TokenSet` is the dict the React SDK persists (under `lowco:tokens:<tenantId>:<clientId>`), so the two can share stored sessions:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
{
|
|
141
|
+
"access_token": "…", # required
|
|
142
|
+
"refresh_token": "…",
|
|
143
|
+
"id_token": "…",
|
|
144
|
+
"expires_at": 1790000000000, # epoch MILLISECONDS (JavaScript Date.now() units)
|
|
145
|
+
"scope": "openid profile email offline_access",
|
|
146
|
+
"token_type": "Bearer",
|
|
147
|
+
"expires_in": 3600, # other response fields are kept, like the React SDK
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`expires_at` is `now_ms + expires_in * 1000`, computed when the tokens arrive. Note the unit: compare it with `time.time() * 1000`, not `time.time()`.
|
|
152
|
+
|
|
153
|
+
`AuthorizationRequest` is a frozen dataclass (`url`, `state`, `nonce`, `code_verifier`, `redirect_uri`, `scope`, `app_state`). `to_dict()` / `AuthorizationRequest.from_dict(...)` turn it into and back from a JSON-serialisable dict, so it can live in a server-side session or an encrypted cookie between the redirect and the callback. `from_dict` needs `state`, `code_verifier` and `redirect_uri` only, so you can drop `url` to save space. `code_verifier` must only travel back to your own server: keep it in the session (server-side, or a signed / encrypted cookie) and never hand it to page scripts or third parties. It is left out of `repr()`.
|
|
154
|
+
|
|
155
|
+
## Errors
|
|
156
|
+
|
|
157
|
+
Flow failures raise `LowcoAuthError` with `message`, `status_code`, `code` and `body`:
|
|
158
|
+
|
|
159
|
+
| Situation | `message` | Other fields |
|
|
160
|
+
| --------- | --------- | ------------ |
|
|
161
|
+
| Token endpoint non-2xx (code grant) | `Authorization code exchange failed.` | `status_code`, `body`, `code` = the response's `error` (e.g. `"invalid_grant"`) |
|
|
162
|
+
| Token endpoint non-2xx (refresh) | `Refresh token exchange failed.` | same |
|
|
163
|
+
| 2xx without a usable `access_token` | `… The token response has no access_token.` | `status_code`, `body` |
|
|
164
|
+
| Callback with `?error=…` | `error_description`, or `Authorization failed (<error>).` | `code` = `error` |
|
|
165
|
+
| Callback without `code` / `state` | `Missing code or state in the OAuth callback URL.` | |
|
|
166
|
+
| Callback `state` differs from the request's | `OAuth state mismatch.` | |
|
|
167
|
+
| `get_valid_tokens` without tokens / expired without refresh token | `Not authenticated.` / `Session expired.` | |
|
|
168
|
+
| `refresh_tokens` with a `TokenSet` lacking `refresh_token` | `No refresh token available.` | |
|
|
169
|
+
|
|
170
|
+
`ValueError` is raised for missing constructor arguments, a missing `redirect_uri`, or a blank `code` / refresh token string. Network failures and timeouts surface as `httpx` exceptions.
|
|
171
|
+
|
|
172
|
+
## Not included
|
|
173
|
+
|
|
174
|
+
Logout in the React SDK only clears local storage and navigates away; there is no server call to port. Delete the stored `TokenSet` from your session to log a user out.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# lowcoai-auth
|
|
2
|
+
|
|
3
|
+
Framework-agnostic Python helpers for the **lowco** OAuth 2.0 Authorization Code + PKCE flow: the server-side counterpart of the React SDK [`@lowcoai/auth`](../../npm/auth), with the same wire behaviour and the same persisted token shape. Works with Flask, Django, FastAPI, Starlette, CLIs with a loopback redirect, and so on. Sync and asyncio clients with the same surface, built on [httpx](https://www.python-httpx.org/). Python 3.10+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install lowcoai-auth
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start (Flask)
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from flask import Flask, redirect, request, session
|
|
13
|
+
from lowcoai.auth import AuthorizationRequest, LowcoAuthClient, LowcoAuthError, get_user
|
|
14
|
+
|
|
15
|
+
app = Flask(__name__)
|
|
16
|
+
app.secret_key = "..."
|
|
17
|
+
|
|
18
|
+
auth = LowcoAuthClient(
|
|
19
|
+
"https://api.lowco.ai/v1/identity", # domain
|
|
20
|
+
"https://auth.lowco.ai/login", # login_page_url
|
|
21
|
+
"tenant_123",
|
|
22
|
+
"client_abc",
|
|
23
|
+
redirect_uri="https://app.example.com/auth/callback",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.get("/login")
|
|
28
|
+
def login():
|
|
29
|
+
req = auth.create_authorization_request(app_state={"returnTo": request.args.get("next", "/")})
|
|
30
|
+
session["lowco_auth"] = req.to_dict() # holds the PKCE verifier until the callback
|
|
31
|
+
return redirect(req.url)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.get("/auth/callback")
|
|
35
|
+
def callback():
|
|
36
|
+
req = AuthorizationRequest.from_dict(session.pop("lowco_auth"))
|
|
37
|
+
try:
|
|
38
|
+
session["tokens"] = auth.handle_redirect_callback(request.url, req)
|
|
39
|
+
except LowcoAuthError as err:
|
|
40
|
+
return f"Sign-in failed: {err.message}", 400
|
|
41
|
+
return redirect((req.app_state or {}).get("returnTo", "/"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.get("/me")
|
|
45
|
+
def me():
|
|
46
|
+
try:
|
|
47
|
+
tokens = auth.get_valid_tokens(session.get("tokens")) # refreshes when needed
|
|
48
|
+
except LowcoAuthError:
|
|
49
|
+
return redirect("/login")
|
|
50
|
+
session["tokens"] = tokens
|
|
51
|
+
return {"user": get_user(tokens), "access_token_expires_at": tokens["expires_at"]}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### asyncio
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from lowcoai.auth import AsyncLowcoAuthClient
|
|
58
|
+
|
|
59
|
+
async with AsyncLowcoAuthClient(
|
|
60
|
+
domain, login_page_url, tenant_id, client_id, redirect_uri=redirect_uri
|
|
61
|
+
) as auth:
|
|
62
|
+
req = auth.create_authorization_request() # sync: no I/O
|
|
63
|
+
tokens = await auth.handle_redirect_callback(callback_url, req)
|
|
64
|
+
tokens = await auth.get_valid_tokens(tokens)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`AsyncLowcoAuthClient` has the same methods as `LowcoAuthClient`; the network methods are coroutines and `create_authorization_request` stays synchronous. Close it with `await auth.aclose()` if you don't use `async with`.
|
|
68
|
+
|
|
69
|
+
### CLI with a loopback redirect
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
req = auth.create_authorization_request(redirect_uri="http://127.0.0.1:8765/callback")
|
|
73
|
+
webbrowser.open(req.url)
|
|
74
|
+
callback_path = wait_for_one_request_on_port(8765) # e.g. "/callback?code=...&state=..."
|
|
75
|
+
tokens = auth.handle_redirect_callback(callback_path, req)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Options
|
|
79
|
+
|
|
80
|
+
| Argument | Description |
|
|
81
|
+
| ---------------- | ----------------------------------------------------------------------------------------- |
|
|
82
|
+
| `domain` | Auth server base, e.g. `https://api.lowco.ai/v1/identity`. Token endpoint: `{domain}/tenants/{tenant_id}/oauth/token`. |
|
|
83
|
+
| `login_page_url` | Hosted login page the user is sent to. A query string already on it is kept. |
|
|
84
|
+
| `tenant_id` | Tenant identifier. |
|
|
85
|
+
| `client_id` | OAuth client identifier (public client: no secret is sent, PKCE protects the code). |
|
|
86
|
+
| `redirect_uri` | Default callback URL. Required here or per `create_authorization_request` call. |
|
|
87
|
+
| `scope` | Default scope, `"openid profile email offline_access"`. |
|
|
88
|
+
| `timeout` | Per-request timeout in seconds (default `30`). `None` disables it. |
|
|
89
|
+
| `http_client` | Your own `httpx.Client` / `httpx.AsyncClient` (proxies, retries, test transports). It is not closed by the SDK. |
|
|
90
|
+
|
|
91
|
+
The client stores nothing per user, so one instance can be shared by the whole app. `auth.token_endpoint` returns the token URL.
|
|
92
|
+
|
|
93
|
+
## Surface
|
|
94
|
+
|
|
95
|
+
| Call | What it does |
|
|
96
|
+
| ---- | ------------ |
|
|
97
|
+
| `create_authorization_request(*, redirect_uri=None, scope=None, app_state=None)` | Generates `state`, `nonce` (24 random bytes each) and a PKCE `code_verifier` (48 bytes), all base64url without padding, and returns an `AuthorizationRequest` whose `url` is `login_page_url` plus `tenant_id`, `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state`, `nonce`, `code_challenge` (S256) and `code_challenge_method=S256`. No I/O. |
|
|
98
|
+
| `handle_redirect_callback(callback_url, request)` | Reads `code` / `state` (full URL, `/path?query` or bare query), raises `LowcoAuthError` on an OAuth `error`, a missing `code` / `state` or a `state` mismatch (constant-time compare), then calls `exchange_code`. |
|
|
99
|
+
| `exchange_code(code, request)` | `POST` form `grant_type=authorization_code`, `tenant_id`, `client_id`, `code`, `code_verifier`, `redirect_uri`. Returns a `TokenSet`. |
|
|
100
|
+
| `refresh_tokens(tokens_or_refresh_token)` | `POST` form `grant_type=refresh_token`, `tenant_id`, `client_id`, `refresh_token`. Given a `TokenSet`, the response is merged over it (a refresh token the server does not rotate is kept). |
|
|
101
|
+
| `get_valid_tokens(tokens)` | Returns `tokens` unchanged (same object) while the access token is valid, refreshes when there is a refresh token, else raises `LowcoAuthError("Session expired.")` (`"Not authenticated."` without tokens). |
|
|
102
|
+
|
|
103
|
+
Module-level helpers:
|
|
104
|
+
|
|
105
|
+
| Helper | Description |
|
|
106
|
+
| ------ | ----------- |
|
|
107
|
+
| `is_access_token_valid(tokens, skew_seconds=60)` | `expires_at` is more than `skew_seconds` in the future. |
|
|
108
|
+
| `can_recover_session(tokens)` | Access token still valid, or a refresh token is present. |
|
|
109
|
+
| `get_user(tokens)` | Claims of the ID token (else the access token) as a `UserProfile`: `sub`, `email`, `name`, `picture` plus every other claim. |
|
|
110
|
+
|
|
111
|
+
> **`get_user` does not verify the JWT signature.** Use it for display only (showing the user's name, avatar, email). For authorisation decisions, validate the access token on the server that receives it.
|
|
112
|
+
|
|
113
|
+
## Data shapes
|
|
114
|
+
|
|
115
|
+
`TokenSet` is the dict the React SDK persists (under `lowco:tokens:<tenantId>:<clientId>`), so the two can share stored sessions:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
{
|
|
119
|
+
"access_token": "…", # required
|
|
120
|
+
"refresh_token": "…",
|
|
121
|
+
"id_token": "…",
|
|
122
|
+
"expires_at": 1790000000000, # epoch MILLISECONDS (JavaScript Date.now() units)
|
|
123
|
+
"scope": "openid profile email offline_access",
|
|
124
|
+
"token_type": "Bearer",
|
|
125
|
+
"expires_in": 3600, # other response fields are kept, like the React SDK
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`expires_at` is `now_ms + expires_in * 1000`, computed when the tokens arrive. Note the unit: compare it with `time.time() * 1000`, not `time.time()`.
|
|
130
|
+
|
|
131
|
+
`AuthorizationRequest` is a frozen dataclass (`url`, `state`, `nonce`, `code_verifier`, `redirect_uri`, `scope`, `app_state`). `to_dict()` / `AuthorizationRequest.from_dict(...)` turn it into and back from a JSON-serialisable dict, so it can live in a server-side session or an encrypted cookie between the redirect and the callback. `from_dict` needs `state`, `code_verifier` and `redirect_uri` only, so you can drop `url` to save space. `code_verifier` must only travel back to your own server: keep it in the session (server-side, or a signed / encrypted cookie) and never hand it to page scripts or third parties. It is left out of `repr()`.
|
|
132
|
+
|
|
133
|
+
## Errors
|
|
134
|
+
|
|
135
|
+
Flow failures raise `LowcoAuthError` with `message`, `status_code`, `code` and `body`:
|
|
136
|
+
|
|
137
|
+
| Situation | `message` | Other fields |
|
|
138
|
+
| --------- | --------- | ------------ |
|
|
139
|
+
| Token endpoint non-2xx (code grant) | `Authorization code exchange failed.` | `status_code`, `body`, `code` = the response's `error` (e.g. `"invalid_grant"`) |
|
|
140
|
+
| Token endpoint non-2xx (refresh) | `Refresh token exchange failed.` | same |
|
|
141
|
+
| 2xx without a usable `access_token` | `… The token response has no access_token.` | `status_code`, `body` |
|
|
142
|
+
| Callback with `?error=…` | `error_description`, or `Authorization failed (<error>).` | `code` = `error` |
|
|
143
|
+
| Callback without `code` / `state` | `Missing code or state in the OAuth callback URL.` | |
|
|
144
|
+
| Callback `state` differs from the request's | `OAuth state mismatch.` | |
|
|
145
|
+
| `get_valid_tokens` without tokens / expired without refresh token | `Not authenticated.` / `Session expired.` | |
|
|
146
|
+
| `refresh_tokens` with a `TokenSet` lacking `refresh_token` | `No refresh token available.` | |
|
|
147
|
+
|
|
148
|
+
`ValueError` is raised for missing constructor arguments, a missing `redirect_uri`, or a blank `code` / refresh token string. Network failures and timeouts surface as `httpx` exceptions.
|
|
149
|
+
|
|
150
|
+
## Not included
|
|
151
|
+
|
|
152
|
+
Logout in the React SDK only clears local storage and navigates away; there is no server call to port. Delete the stored `TokenSet` from your session to log a user out.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lowcoai-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Framework-agnostic Python helpers for the lowco OAuth 2.0 Authorization Code + PKCE flow."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.25,<1",
|
|
14
|
+
"typing-extensions>=4.7",
|
|
15
|
+
]
|
|
16
|
+
keywords = ["auth", "oauth", "oauth2", "pkce", "oidc", "lowco", "sdk"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
"Framework :: AsyncIO",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/lowcoai/lowco-sdk"
|
|
25
|
+
Source = "https://github.com/lowcoai/lowco-sdk/tree/main/python/auth"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.10", "ruff>=0.5"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/lowcoai"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
pythonpath = ["src"]
|
|
35
|
+
asyncio_mode = "auto"
|
|
36
|
+
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
strict = true
|
|
39
|
+
mypy_path = "src"
|
|
40
|
+
explicit_package_bases = true
|
|
41
|
+
warn_return_any = false
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 100
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F", "W", "I", "UP", "B"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Framework-agnostic helpers for the lowco OAuth 2.0 Authorization Code + PKCE flow."""
|
|
2
|
+
|
|
3
|
+
from . import types as _types
|
|
4
|
+
from ._base import DEFAULT_SCOPE
|
|
5
|
+
from ._client import AsyncLowcoAuthClient, LowcoAuthClient
|
|
6
|
+
from ._errors import LowcoAuthError
|
|
7
|
+
from ._tokens import (
|
|
8
|
+
TOKEN_REFRESH_SKEW_SECONDS,
|
|
9
|
+
can_recover_session,
|
|
10
|
+
get_user,
|
|
11
|
+
is_access_token_valid,
|
|
12
|
+
)
|
|
13
|
+
from .types import * # noqa: F403
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_SCOPE",
|
|
19
|
+
"TOKEN_REFRESH_SKEW_SECONDS",
|
|
20
|
+
"AsyncLowcoAuthClient",
|
|
21
|
+
"LowcoAuthClient",
|
|
22
|
+
"LowcoAuthError",
|
|
23
|
+
"__version__",
|
|
24
|
+
"can_recover_session",
|
|
25
|
+
"get_user",
|
|
26
|
+
"is_access_token_valid",
|
|
27
|
+
]
|
|
28
|
+
__all__ += _types.__all__
|