incorta-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.
- incorta_auth-0.1.0/.gitignore +6 -0
- incorta_auth-0.1.0/.python-version +1 -0
- incorta_auth-0.1.0/PKG-INFO +210 -0
- incorta_auth-0.1.0/README.md +189 -0
- incorta_auth-0.1.0/pyproject.toml +73 -0
- incorta_auth-0.1.0/src/incorta_auth/__init__.py +77 -0
- incorta_auth-0.1.0/src/incorta_auth/_time.py +11 -0
- incorta_auth-0.1.0/src/incorta_auth/asgi.py +438 -0
- incorta_auth-0.1.0/src/incorta_auth/auth.py +69 -0
- incorta_auth-0.1.0/src/incorta_auth/config.py +226 -0
- incorta_auth-0.1.0/src/incorta_auth/cookies.py +79 -0
- incorta_auth-0.1.0/src/incorta_auth/discovery.py +80 -0
- incorta_auth-0.1.0/src/incorta_auth/errors.py +6 -0
- incorta_auth-0.1.0/src/incorta_auth/fastapi.py +37 -0
- incorta_auth-0.1.0/src/incorta_auth/flow.py +289 -0
- incorta_auth-0.1.0/src/incorta_auth/jwks.py +73 -0
- incorta_auth-0.1.0/src/incorta_auth/py.typed +0 -0
- incorta_auth-0.1.0/src/incorta_auth/register.py +232 -0
- incorta_auth-0.1.0/src/incorta_auth/session.py +195 -0
- incorta_auth-0.1.0/src/incorta_auth/streamlit.py +386 -0
- incorta_auth-0.1.0/src/incorta_auth/tokens.py +165 -0
- incorta_auth-0.1.0/tests/__init__.py +0 -0
- incorta_auth-0.1.0/tests/conftest.py +33 -0
- incorta_auth-0.1.0/tests/fixtures/generate-fixtures.mjs +71 -0
- incorta_auth-0.1.0/tests/fixtures/sealed-fixtures.json +30 -0
- incorta_auth-0.1.0/tests/mock_incorta.py +434 -0
- incorta_auth-0.1.0/tests/test_asgi.py +231 -0
- incorta_auth-0.1.0/tests/test_config.py +103 -0
- incorta_auth-0.1.0/tests/test_flow.py +315 -0
- incorta_auth-0.1.0/tests/test_mock_quirks.py +83 -0
- incorta_auth-0.1.0/tests/test_register.py +169 -0
- incorta_auth-0.1.0/tests/test_session.py +164 -0
- incorta_auth-0.1.0/tests/test_split_horizon.py +143 -0
- incorta_auth-0.1.0/tests/test_streamlit.py +282 -0
- incorta_auth-0.1.0/tests/util.py +68 -0
- incorta_auth-0.1.0/uv.lock +2339 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: incorta-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Authenticate Incorta users in Python apps via Incorta's built-in OAuth 2.0 / OIDC authorization server
|
|
5
|
+
Project-URL: Repository, https://github.com/Incorta/IncortaAuthSDK
|
|
6
|
+
Author: Incorta
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: jwcrypto>=1.5.6
|
|
19
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# incorta-auth (Python)
|
|
23
|
+
|
|
24
|
+
[](https://pypi.org/project/incorta-auth/)
|
|
25
|
+
[](../../LICENSE)
|
|
26
|
+
|
|
27
|
+
Authenticate Incorta users in Python apps. The Python port of
|
|
28
|
+
[`@incorta/auth`](https://www.npmjs.com/package/@incorta/auth) — it gates your
|
|
29
|
+
app behind the **real Incorta login** (no custom login page) using the OAuth
|
|
30
|
+
2.0 / OIDC authorization server built into Incorta.
|
|
31
|
+
|
|
32
|
+
Both SDKs implement one spec: the same OAuth authorization-code flow against
|
|
33
|
+
`{INCORTA_URL}/oauth/{tenant}`, the same `INCORTA_*` configuration, and the
|
|
34
|
+
same encrypted-cookie sealing (compact JWE, `dir` + A256GCM, key derived
|
|
35
|
+
`SHA-256("incorta-auth:" + secret)`) — cookies sealed by either language
|
|
36
|
+
unseal in the other, verified by fixtures generated with the TS
|
|
37
|
+
implementation (`tests/fixtures/`).
|
|
38
|
+
|
|
39
|
+
## Status
|
|
40
|
+
|
|
41
|
+
- **Core protocol layer**: configuration, endpoint discovery with
|
|
42
|
+
split-horizon support, authorization-code exchange, silent refresh with
|
|
43
|
+
rotation, RS256/JWKS token verification, session sealing, and RFC 7591/7592
|
|
44
|
+
client registration as library functions.
|
|
45
|
+
- **ASGI / FastAPI adapter**: `IncortaAuthMiddleware` gates any ASGI app
|
|
46
|
+
behind Incorta login and serves `/auth/*`; `incorta_auth.fastapi` provides
|
|
47
|
+
the `get_session` dependency. See `examples/fastapi`.
|
|
48
|
+
- **Streamlit adapter**: `incorta_auth.streamlit.gate()` gates a Streamlit
|
|
49
|
+
app (which has no routes and no response-cookie control) behind Incorta
|
|
50
|
+
login. See `examples/streamlit` and the design notes below.
|
|
51
|
+
|
|
52
|
+
## Usage (FastAPI / any ASGI app)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from fastapi import Depends, FastAPI
|
|
56
|
+
from incorta_auth import AuthSession
|
|
57
|
+
from incorta_auth.asgi import IncortaAuthMiddleware
|
|
58
|
+
from incorta_auth.fastapi import get_session
|
|
59
|
+
|
|
60
|
+
app = FastAPI()
|
|
61
|
+
app.add_middleware(IncortaAuthMiddleware) # config from INCORTA_* env vars
|
|
62
|
+
|
|
63
|
+
@app.get("/api/me")
|
|
64
|
+
def me(session: AuthSession = Depends(get_session)):
|
|
65
|
+
return {"user": session.user.sub, "roles": session.user.roles}
|
|
66
|
+
# session.access_token → Authorization: Bearer for Incorta REST APIs
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The middleware serves `{base_path}/{login,callback,session,logout,signed-out}`
|
|
70
|
+
(+ `/token` when `expose_access_token=True`), attaches the session to
|
|
71
|
+
`request.state.incorta_auth`, redirects signed-out browser navigations into
|
|
72
|
+
Incorta login (relative `Location` — proxy-safe), 401s signed-out data
|
|
73
|
+
requests, refuses signed-out websockets, and answers everything with an
|
|
74
|
+
honest 503 while the `INCORTA_*` env is missing. `public_paths=("/api/health",)`
|
|
75
|
+
exempts liveness probes from both the gate and the 503. For apps embedded in
|
|
76
|
+
a cross-site iframe, spread `embedded_cookie_config()` into the middleware
|
|
77
|
+
(SameSite=None + Partitioned).
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uv add incorta-auth # or: pip install incorta-auth
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Prerequisites
|
|
86
|
+
|
|
87
|
+
Same as the TS SDK (see the [repo README](../../README.md)): enable the
|
|
88
|
+
authorization server on the cluster, then register an OAuth client. The
|
|
89
|
+
registration CLI ships with the npm package —
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx incorta-auth register \
|
|
93
|
+
--url http://localhost:8080/incorta \
|
|
94
|
+
--tenant demo \
|
|
95
|
+
--name "My App" \
|
|
96
|
+
--redirect http://localhost:4000/auth/callback
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
— or use the library functions below from Python.
|
|
100
|
+
|
|
101
|
+
## Usage (core)
|
|
102
|
+
|
|
103
|
+
Configuration comes from `INCORTA_*` env vars (`INCORTA_URL`,
|
|
104
|
+
`INCORTA_TENANT`, `INCORTA_CLIENT_ID`, `INCORTA_CLIENT_SECRET`,
|
|
105
|
+
`INCORTA_AUTH_SECRET`, optional `INCORTA_APP_URL` / `INCORTA_INTERNAL_URL` /
|
|
106
|
+
`INCORTA_COOKIE_SAMESITE` / `INCORTA_COOKIE_PARTITIONED`) or explicit
|
|
107
|
+
keyword arguments:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from incorta_auth import IncortaAuth
|
|
111
|
+
|
|
112
|
+
auth = IncortaAuth() # reads INCORTA_* env vars
|
|
113
|
+
|
|
114
|
+
# Send the browser to Incorta login:
|
|
115
|
+
login = auth.begin_login(origin="http://localhost:4000", secure=False, redirect_to="/")
|
|
116
|
+
# → redirect to login.location, set login.set_cookies
|
|
117
|
+
|
|
118
|
+
# Complete the callback (?code&state):
|
|
119
|
+
result = auth.complete_callback(
|
|
120
|
+
origin="http://localhost:4000", secure=False,
|
|
121
|
+
cookie_header=request_cookie_header, query=dict(query_params),
|
|
122
|
+
)
|
|
123
|
+
# → redirect to result.location, set result.set_cookies
|
|
124
|
+
|
|
125
|
+
# Gate requests / read the user and token:
|
|
126
|
+
session = auth.get_session(request_cookie_header)
|
|
127
|
+
if session:
|
|
128
|
+
session.user.sub, session.user.roles, session.access_token
|
|
129
|
+
|
|
130
|
+
# Refresh-aware load (rotates the refresh token, slides the session cookie):
|
|
131
|
+
loaded = auth.load_session(cookie_header=request_cookie_header, secure=False)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Client registration (RFC 7591/7592):
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from incorta_auth import register_client, get_client, update_client_redirect_uris, delete_client
|
|
138
|
+
|
|
139
|
+
client = register_client(
|
|
140
|
+
incorta_url="http://localhost:8080/incorta", tenant="demo",
|
|
141
|
+
name="My App", redirect_uris=["http://localhost:4000/auth/callback"],
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Usage (Streamlit)
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import streamlit as st
|
|
149
|
+
import incorta_auth.streamlit as incorta
|
|
150
|
+
|
|
151
|
+
user = incorta.gate() # first line of the entry script; st.stop()s until signed in
|
|
152
|
+
st.write(f"Hello {user.name}", user.roles)
|
|
153
|
+
incorta.access_token() # Bearer for Incorta REST APIs (auto-refreshed)
|
|
154
|
+
if st.button("Sign out"):
|
|
155
|
+
incorta.sign_out() # interstitial — SSO would otherwise bounce right back in
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The OAuth `redirect_uri` is the app **root** (`{INCORTA_APP_URL}/`) —
|
|
159
|
+
register exactly that. Tokens live only in `st.session_state`; a hard
|
|
160
|
+
refresh silently re-authenticates through Incorta SSO. Multi-page entry
|
|
161
|
+
scripts can restore the originally requested page with `pop_return_path()`.
|
|
162
|
+
|
|
163
|
+
### Streamlit design notes (browser-spike findings, 2026-07-20)
|
|
164
|
+
|
|
165
|
+
Streamlit cannot host a callback route, set response cookies, or (it turns
|
|
166
|
+
out) navigate from component JS — the flow is built from the three vehicles
|
|
167
|
+
a real-browser spike (Playwright, Streamlit 1.59) validated:
|
|
168
|
+
|
|
169
|
+
1. **Navigation**: `components.html` iframes are sandboxed
|
|
170
|
+
`allow-same-origin allow-scripts …` **without** `allow-top-navigation`,
|
|
171
|
+
so JS cannot redirect the app. `st.html` strips both scripts and meta
|
|
172
|
+
tags (DOMPurify). The one silent vehicle is a
|
|
173
|
+
`st.markdown(unsafe_allow_html=True)` **meta-refresh**, which navigates
|
|
174
|
+
the app frame itself — allowed everywhere, including inside iframes.
|
|
175
|
+
2. **State cookie**: `components.html` JS **can** write cookies on the app
|
|
176
|
+
origin (`allow-same-origin` + srcdoc). The sealed login state
|
|
177
|
+
(state/nonce/return target, same JWE sealing as the TS SDK's state
|
|
178
|
+
cookie) is written in two variants — `SameSite=Lax` for top-level tabs
|
|
179
|
+
and `SameSite=None; Secure; Partitioned` for cross-site iframes (the
|
|
180
|
+
Incorta builder preview; verified in a genuine cross-site embed) — and
|
|
181
|
+
read back after the round-trip via `st.context.cookies`.
|
|
182
|
+
3. **Ordering**: the cookie write reliably precedes the meta navigation
|
|
183
|
+
(5/5 in the spike) — both are same-turn DOM operations and the srcdoc
|
|
184
|
+
iframe has no network dependency. Binding is **strict**: a missing state
|
|
185
|
+
cookie fails the callback with `state_missing` (the same failure a
|
|
186
|
+
cookie-blocked browser gets from the TS SDK) and offers a retry button.
|
|
187
|
+
|
|
188
|
+
Compatibility note: the meta-refresh passthrough and the component sandbox
|
|
189
|
+
flags are Streamlit implementation details — the full flow is pinned by a
|
|
190
|
+
browser e2e and the AppTest suite, so a Streamlit upgrade that changes
|
|
191
|
+
either fails loudly, not silently.
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
uv sync
|
|
197
|
+
uv run pytest
|
|
198
|
+
uv run ruff check . && uv run ruff format --check .
|
|
199
|
+
uv run mypy
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The test suite runs against an in-process replica of Incorta's authorization
|
|
203
|
+
server (`tests/mock_incorta.py`, a port of the TS suite's `mock-incorta.ts`)
|
|
204
|
+
served through `httpx.MockTransport` — one-time 60s codes, rotating refresh
|
|
205
|
+
tokens, and split-horizon simulation included. Regenerate the cross-language
|
|
206
|
+
sealing fixtures with:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
node tests/fixtures/generate-fixtures.mjs > tests/fixtures/sealed-fixtures.json
|
|
210
|
+
```
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# incorta-auth (Python)
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/incorta-auth/)
|
|
4
|
+
[](../../LICENSE)
|
|
5
|
+
|
|
6
|
+
Authenticate Incorta users in Python apps. The Python port of
|
|
7
|
+
[`@incorta/auth`](https://www.npmjs.com/package/@incorta/auth) — it gates your
|
|
8
|
+
app behind the **real Incorta login** (no custom login page) using the OAuth
|
|
9
|
+
2.0 / OIDC authorization server built into Incorta.
|
|
10
|
+
|
|
11
|
+
Both SDKs implement one spec: the same OAuth authorization-code flow against
|
|
12
|
+
`{INCORTA_URL}/oauth/{tenant}`, the same `INCORTA_*` configuration, and the
|
|
13
|
+
same encrypted-cookie sealing (compact JWE, `dir` + A256GCM, key derived
|
|
14
|
+
`SHA-256("incorta-auth:" + secret)`) — cookies sealed by either language
|
|
15
|
+
unseal in the other, verified by fixtures generated with the TS
|
|
16
|
+
implementation (`tests/fixtures/`).
|
|
17
|
+
|
|
18
|
+
## Status
|
|
19
|
+
|
|
20
|
+
- **Core protocol layer**: configuration, endpoint discovery with
|
|
21
|
+
split-horizon support, authorization-code exchange, silent refresh with
|
|
22
|
+
rotation, RS256/JWKS token verification, session sealing, and RFC 7591/7592
|
|
23
|
+
client registration as library functions.
|
|
24
|
+
- **ASGI / FastAPI adapter**: `IncortaAuthMiddleware` gates any ASGI app
|
|
25
|
+
behind Incorta login and serves `/auth/*`; `incorta_auth.fastapi` provides
|
|
26
|
+
the `get_session` dependency. See `examples/fastapi`.
|
|
27
|
+
- **Streamlit adapter**: `incorta_auth.streamlit.gate()` gates a Streamlit
|
|
28
|
+
app (which has no routes and no response-cookie control) behind Incorta
|
|
29
|
+
login. See `examples/streamlit` and the design notes below.
|
|
30
|
+
|
|
31
|
+
## Usage (FastAPI / any ASGI app)
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from fastapi import Depends, FastAPI
|
|
35
|
+
from incorta_auth import AuthSession
|
|
36
|
+
from incorta_auth.asgi import IncortaAuthMiddleware
|
|
37
|
+
from incorta_auth.fastapi import get_session
|
|
38
|
+
|
|
39
|
+
app = FastAPI()
|
|
40
|
+
app.add_middleware(IncortaAuthMiddleware) # config from INCORTA_* env vars
|
|
41
|
+
|
|
42
|
+
@app.get("/api/me")
|
|
43
|
+
def me(session: AuthSession = Depends(get_session)):
|
|
44
|
+
return {"user": session.user.sub, "roles": session.user.roles}
|
|
45
|
+
# session.access_token → Authorization: Bearer for Incorta REST APIs
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The middleware serves `{base_path}/{login,callback,session,logout,signed-out}`
|
|
49
|
+
(+ `/token` when `expose_access_token=True`), attaches the session to
|
|
50
|
+
`request.state.incorta_auth`, redirects signed-out browser navigations into
|
|
51
|
+
Incorta login (relative `Location` — proxy-safe), 401s signed-out data
|
|
52
|
+
requests, refuses signed-out websockets, and answers everything with an
|
|
53
|
+
honest 503 while the `INCORTA_*` env is missing. `public_paths=("/api/health",)`
|
|
54
|
+
exempts liveness probes from both the gate and the 503. For apps embedded in
|
|
55
|
+
a cross-site iframe, spread `embedded_cookie_config()` into the middleware
|
|
56
|
+
(SameSite=None + Partitioned).
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv add incorta-auth # or: pip install incorta-auth
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Prerequisites
|
|
65
|
+
|
|
66
|
+
Same as the TS SDK (see the [repo README](../../README.md)): enable the
|
|
67
|
+
authorization server on the cluster, then register an OAuth client. The
|
|
68
|
+
registration CLI ships with the npm package —
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx incorta-auth register \
|
|
72
|
+
--url http://localhost:8080/incorta \
|
|
73
|
+
--tenant demo \
|
|
74
|
+
--name "My App" \
|
|
75
|
+
--redirect http://localhost:4000/auth/callback
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
— or use the library functions below from Python.
|
|
79
|
+
|
|
80
|
+
## Usage (core)
|
|
81
|
+
|
|
82
|
+
Configuration comes from `INCORTA_*` env vars (`INCORTA_URL`,
|
|
83
|
+
`INCORTA_TENANT`, `INCORTA_CLIENT_ID`, `INCORTA_CLIENT_SECRET`,
|
|
84
|
+
`INCORTA_AUTH_SECRET`, optional `INCORTA_APP_URL` / `INCORTA_INTERNAL_URL` /
|
|
85
|
+
`INCORTA_COOKIE_SAMESITE` / `INCORTA_COOKIE_PARTITIONED`) or explicit
|
|
86
|
+
keyword arguments:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from incorta_auth import IncortaAuth
|
|
90
|
+
|
|
91
|
+
auth = IncortaAuth() # reads INCORTA_* env vars
|
|
92
|
+
|
|
93
|
+
# Send the browser to Incorta login:
|
|
94
|
+
login = auth.begin_login(origin="http://localhost:4000", secure=False, redirect_to="/")
|
|
95
|
+
# → redirect to login.location, set login.set_cookies
|
|
96
|
+
|
|
97
|
+
# Complete the callback (?code&state):
|
|
98
|
+
result = auth.complete_callback(
|
|
99
|
+
origin="http://localhost:4000", secure=False,
|
|
100
|
+
cookie_header=request_cookie_header, query=dict(query_params),
|
|
101
|
+
)
|
|
102
|
+
# → redirect to result.location, set result.set_cookies
|
|
103
|
+
|
|
104
|
+
# Gate requests / read the user and token:
|
|
105
|
+
session = auth.get_session(request_cookie_header)
|
|
106
|
+
if session:
|
|
107
|
+
session.user.sub, session.user.roles, session.access_token
|
|
108
|
+
|
|
109
|
+
# Refresh-aware load (rotates the refresh token, slides the session cookie):
|
|
110
|
+
loaded = auth.load_session(cookie_header=request_cookie_header, secure=False)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Client registration (RFC 7591/7592):
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from incorta_auth import register_client, get_client, update_client_redirect_uris, delete_client
|
|
117
|
+
|
|
118
|
+
client = register_client(
|
|
119
|
+
incorta_url="http://localhost:8080/incorta", tenant="demo",
|
|
120
|
+
name="My App", redirect_uris=["http://localhost:4000/auth/callback"],
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Usage (Streamlit)
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import streamlit as st
|
|
128
|
+
import incorta_auth.streamlit as incorta
|
|
129
|
+
|
|
130
|
+
user = incorta.gate() # first line of the entry script; st.stop()s until signed in
|
|
131
|
+
st.write(f"Hello {user.name}", user.roles)
|
|
132
|
+
incorta.access_token() # Bearer for Incorta REST APIs (auto-refreshed)
|
|
133
|
+
if st.button("Sign out"):
|
|
134
|
+
incorta.sign_out() # interstitial — SSO would otherwise bounce right back in
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The OAuth `redirect_uri` is the app **root** (`{INCORTA_APP_URL}/`) —
|
|
138
|
+
register exactly that. Tokens live only in `st.session_state`; a hard
|
|
139
|
+
refresh silently re-authenticates through Incorta SSO. Multi-page entry
|
|
140
|
+
scripts can restore the originally requested page with `pop_return_path()`.
|
|
141
|
+
|
|
142
|
+
### Streamlit design notes (browser-spike findings, 2026-07-20)
|
|
143
|
+
|
|
144
|
+
Streamlit cannot host a callback route, set response cookies, or (it turns
|
|
145
|
+
out) navigate from component JS — the flow is built from the three vehicles
|
|
146
|
+
a real-browser spike (Playwright, Streamlit 1.59) validated:
|
|
147
|
+
|
|
148
|
+
1. **Navigation**: `components.html` iframes are sandboxed
|
|
149
|
+
`allow-same-origin allow-scripts …` **without** `allow-top-navigation`,
|
|
150
|
+
so JS cannot redirect the app. `st.html` strips both scripts and meta
|
|
151
|
+
tags (DOMPurify). The one silent vehicle is a
|
|
152
|
+
`st.markdown(unsafe_allow_html=True)` **meta-refresh**, which navigates
|
|
153
|
+
the app frame itself — allowed everywhere, including inside iframes.
|
|
154
|
+
2. **State cookie**: `components.html` JS **can** write cookies on the app
|
|
155
|
+
origin (`allow-same-origin` + srcdoc). The sealed login state
|
|
156
|
+
(state/nonce/return target, same JWE sealing as the TS SDK's state
|
|
157
|
+
cookie) is written in two variants — `SameSite=Lax` for top-level tabs
|
|
158
|
+
and `SameSite=None; Secure; Partitioned` for cross-site iframes (the
|
|
159
|
+
Incorta builder preview; verified in a genuine cross-site embed) — and
|
|
160
|
+
read back after the round-trip via `st.context.cookies`.
|
|
161
|
+
3. **Ordering**: the cookie write reliably precedes the meta navigation
|
|
162
|
+
(5/5 in the spike) — both are same-turn DOM operations and the srcdoc
|
|
163
|
+
iframe has no network dependency. Binding is **strict**: a missing state
|
|
164
|
+
cookie fails the callback with `state_missing` (the same failure a
|
|
165
|
+
cookie-blocked browser gets from the TS SDK) and offers a retry button.
|
|
166
|
+
|
|
167
|
+
Compatibility note: the meta-refresh passthrough and the component sandbox
|
|
168
|
+
flags are Streamlit implementation details — the full flow is pinned by a
|
|
169
|
+
browser e2e and the AppTest suite, so a Streamlit upgrade that changes
|
|
170
|
+
either fails loudly, not silently.
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
uv sync
|
|
176
|
+
uv run pytest
|
|
177
|
+
uv run ruff check . && uv run ruff format --check .
|
|
178
|
+
uv run mypy
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The test suite runs against an in-process replica of Incorta's authorization
|
|
182
|
+
server (`tests/mock_incorta.py`, a port of the TS suite's `mock-incorta.ts`)
|
|
183
|
+
served through `httpx.MockTransport` — one-time 60s codes, rotating refresh
|
|
184
|
+
tokens, and split-horizon simulation included. Regenerate the cross-language
|
|
185
|
+
sealing fixtures with:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
node tests/fixtures/generate-fixtures.mjs > tests/fixtures/sealed-fixtures.json
|
|
189
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "incorta-auth"
|
|
3
|
+
# Placeholder — the real version is computed from git history by
|
|
4
|
+
# semantic-release and stamped here (uv version) only in CI at publish time.
|
|
5
|
+
# See release.python.config.cjs; release tags are py-v{version}.
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
description = "Authenticate Incorta users in Python apps via Incorta's built-in OAuth 2.0 / OIDC authorization server"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
authors = [{ name = "Incorta" }]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"pyjwt[crypto]>=2.8",
|
|
15
|
+
"jwcrypto>=1.5.6",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/Incorta/IncortaAuthSDK"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.3",
|
|
34
|
+
"ruff>=0.8",
|
|
35
|
+
"mypy>=1.13",
|
|
36
|
+
# For the adapter tests (TestClient incl. websockets) and typing the
|
|
37
|
+
# incorta_auth.fastapi module; the published package does NOT depend on it.
|
|
38
|
+
"fastapi>=0.115",
|
|
39
|
+
"streamlit>=1.44",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/incorta_auth"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py310"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
python_version = "3.10"
|
|
61
|
+
strict = true
|
|
62
|
+
files = ["src"]
|
|
63
|
+
|
|
64
|
+
[[tool.mypy.overrides]]
|
|
65
|
+
module = "jwcrypto.*"
|
|
66
|
+
ignore_missing_imports = true
|
|
67
|
+
|
|
68
|
+
# Streamlit's dependency tree (numpy etc.) ships 3.12-only stub syntax that
|
|
69
|
+
# breaks analysis under our 3.10 floor — treat the framework as untyped.
|
|
70
|
+
[[tool.mypy.overrides]]
|
|
71
|
+
module = "streamlit.*"
|
|
72
|
+
follow_imports = "skip"
|
|
73
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Authenticate Incorta users in Python apps.
|
|
2
|
+
|
|
3
|
+
Python port of ``@incorta/auth`` — OAuth 2.0 / OIDC against the authorization
|
|
4
|
+
server built into Incorta, with sessions sealed in encrypted cookies that
|
|
5
|
+
interop with the TS SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError
|
|
9
|
+
from importlib.metadata import version as _metadata_version
|
|
10
|
+
|
|
11
|
+
from .asgi import IncortaAuthMiddleware, is_auth_configured
|
|
12
|
+
from .auth import IncortaAuth
|
|
13
|
+
from .config import (
|
|
14
|
+
AuthSession,
|
|
15
|
+
IncortaUser,
|
|
16
|
+
ResolvedConfig,
|
|
17
|
+
SessionData,
|
|
18
|
+
embedded_cookie_config,
|
|
19
|
+
resolve_config,
|
|
20
|
+
)
|
|
21
|
+
from .errors import IncortaAuthError
|
|
22
|
+
from .flow import (
|
|
23
|
+
ACCESS_TOKEN_REFRESH_LEEWAY_MS,
|
|
24
|
+
CallbackResult,
|
|
25
|
+
LoadedSession,
|
|
26
|
+
LoginResult,
|
|
27
|
+
begin_login,
|
|
28
|
+
clear_session_cookie,
|
|
29
|
+
complete_callback,
|
|
30
|
+
get_session,
|
|
31
|
+
load_session,
|
|
32
|
+
validate_return_to,
|
|
33
|
+
)
|
|
34
|
+
from .register import (
|
|
35
|
+
ClientInfo,
|
|
36
|
+
RegisteredClient,
|
|
37
|
+
delete_client,
|
|
38
|
+
get_client,
|
|
39
|
+
register_client,
|
|
40
|
+
update_client_redirect_uris,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# The distribution's version is stamped into package metadata at publish time
|
|
44
|
+
# (see release.python.config.cjs) — read it back rather than hardcoding.
|
|
45
|
+
try:
|
|
46
|
+
__version__ = _metadata_version("incorta-auth")
|
|
47
|
+
except PackageNotFoundError: # bare checkout without an installed dist
|
|
48
|
+
__version__ = "0.0.0"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"ACCESS_TOKEN_REFRESH_LEEWAY_MS",
|
|
52
|
+
"AuthSession",
|
|
53
|
+
"CallbackResult",
|
|
54
|
+
"ClientInfo",
|
|
55
|
+
"IncortaAuth",
|
|
56
|
+
"IncortaAuthError",
|
|
57
|
+
"IncortaAuthMiddleware",
|
|
58
|
+
"IncortaUser",
|
|
59
|
+
"LoadedSession",
|
|
60
|
+
"LoginResult",
|
|
61
|
+
"RegisteredClient",
|
|
62
|
+
"ResolvedConfig",
|
|
63
|
+
"SessionData",
|
|
64
|
+
"begin_login",
|
|
65
|
+
"clear_session_cookie",
|
|
66
|
+
"complete_callback",
|
|
67
|
+
"delete_client",
|
|
68
|
+
"embedded_cookie_config",
|
|
69
|
+
"get_client",
|
|
70
|
+
"get_session",
|
|
71
|
+
"is_auth_configured",
|
|
72
|
+
"load_session",
|
|
73
|
+
"register_client",
|
|
74
|
+
"resolve_config",
|
|
75
|
+
"update_client_redirect_uris",
|
|
76
|
+
"validate_return_to",
|
|
77
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Clock indirection so tests can shift time without sleeping.
|
|
2
|
+
|
|
3
|
+
Call sites use ``_time.now_ms()`` (module attribute lookup) rather than
|
|
4
|
+
importing the function, so a monkeypatch here is seen everywhere.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def now_ms() -> int:
|
|
11
|
+
return int(time.time() * 1000)
|