incorta-auth 0.1.0__py3-none-any.whl
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/__init__.py +77 -0
- incorta_auth/_time.py +11 -0
- incorta_auth/asgi.py +438 -0
- incorta_auth/auth.py +69 -0
- incorta_auth/config.py +226 -0
- incorta_auth/cookies.py +79 -0
- incorta_auth/discovery.py +80 -0
- incorta_auth/errors.py +6 -0
- incorta_auth/fastapi.py +37 -0
- incorta_auth/flow.py +289 -0
- incorta_auth/jwks.py +73 -0
- incorta_auth/py.typed +0 -0
- incorta_auth/register.py +232 -0
- incorta_auth/session.py +195 -0
- incorta_auth/streamlit.py +386 -0
- incorta_auth/tokens.py +165 -0
- incorta_auth-0.1.0.dist-info/METADATA +210 -0
- incorta_auth-0.1.0.dist-info/RECORD +19 -0
- incorta_auth-0.1.0.dist-info/WHEEL +4 -0
incorta_auth/__init__.py
ADDED
|
@@ -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
|
+
]
|
incorta_auth/_time.py
ADDED
|
@@ -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)
|
incorta_auth/asgi.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Pure-ASGI auth middleware — the Python equivalent of the fullstack
|
|
2
|
+
template's ``src/server.ts`` + ``src/server/auth.ts`` gate combined with the
|
|
3
|
+
TS SDK's ``/auth/*`` handler:
|
|
4
|
+
|
|
5
|
+
- ``{base_path}/*`` is served by the SDK (login, callback, session, logout,
|
|
6
|
+
signed-out, and token when exposed);
|
|
7
|
+
- every other request needs a session: browser navigations are redirected
|
|
8
|
+
into the Incorta login flow (relative ``Location`` on purpose — behind a
|
|
9
|
+
Host-rewriting proxy only the browser knows the public origin), data
|
|
10
|
+
requests get a 401, websockets are refused;
|
|
11
|
+
- authenticated requests continue to the app with the session attached to
|
|
12
|
+
the ASGI ``state`` (``request.state.incorta_auth`` in Starlette/FastAPI);
|
|
13
|
+
- when the INCORTA_* env is missing every request gets an honest 503
|
|
14
|
+
"authentication not configured" answer instead of a crash;
|
|
15
|
+
- ``public_paths`` (exact matches) bypass the gate AND the 503 — liveness
|
|
16
|
+
probes keep working before provisioning completes.
|
|
17
|
+
|
|
18
|
+
No Starlette/FastAPI dependency — this wraps any ASGI app. Network-touching
|
|
19
|
+
work (token exchange, refresh, JWKS) runs in the default executor so the
|
|
20
|
+
event loop never blocks on the sync core.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import functools
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
from collections.abc import Awaitable, Callable, MutableMapping, Sequence
|
|
31
|
+
from typing import Any
|
|
32
|
+
from urllib.parse import parse_qsl, quote
|
|
33
|
+
|
|
34
|
+
from . import flow
|
|
35
|
+
from .auth import IncortaAuth
|
|
36
|
+
from .config import ResolvedConfig
|
|
37
|
+
from .errors import IncortaAuthError
|
|
38
|
+
from .session import _user_to_payload
|
|
39
|
+
|
|
40
|
+
Scope = MutableMapping[str, Any]
|
|
41
|
+
Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
|
|
42
|
+
Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
|
|
43
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("incorta_auth")
|
|
46
|
+
|
|
47
|
+
_REQUIRED = {
|
|
48
|
+
"incorta_url": "INCORTA_URL",
|
|
49
|
+
"tenant": "INCORTA_TENANT",
|
|
50
|
+
"client_id": "INCORTA_CLIENT_ID",
|
|
51
|
+
"client_secret": "INCORTA_CLIENT_SECRET",
|
|
52
|
+
"secret": "INCORTA_AUTH_SECRET",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_auth_configured(overrides: dict[str, Any] | None = None) -> bool:
|
|
57
|
+
"""True when every required setting is present (argument or env) —
|
|
58
|
+
mirrors the fullstack template's ``isAuthConfigured``."""
|
|
59
|
+
provided = overrides or {}
|
|
60
|
+
return all(
|
|
61
|
+
str(provided.get(field) or os.environ.get(env_var) or "").strip() != ""
|
|
62
|
+
for field, env_var in _REQUIRED.items()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Response:
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
status: int,
|
|
70
|
+
body: bytes = b"",
|
|
71
|
+
content_type: str | None = None,
|
|
72
|
+
extra_headers: list[tuple[str, str]] | None = None,
|
|
73
|
+
cookies: list[str] | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.status = status
|
|
76
|
+
self.body = body
|
|
77
|
+
self.headers: list[tuple[str, str]] = []
|
|
78
|
+
if content_type:
|
|
79
|
+
self.headers.append(("content-type", content_type))
|
|
80
|
+
self.headers.extend(extra_headers or [])
|
|
81
|
+
# One header entry per cookie — Set-Cookie must never be folded.
|
|
82
|
+
self.headers.extend(("set-cookie", cookie) for cookie in cookies or [])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _json(status: int, body: Any, cookies: list[str] | None = None) -> _Response:
|
|
86
|
+
return _Response(
|
|
87
|
+
status,
|
|
88
|
+
json.dumps(body).encode(),
|
|
89
|
+
content_type="application/json; charset=utf-8",
|
|
90
|
+
extra_headers=[("cache-control", "no-store")],
|
|
91
|
+
cookies=cookies,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _redirect(location: str, cookies: list[str] | None = None) -> _Response:
|
|
96
|
+
return _Response(
|
|
97
|
+
302,
|
|
98
|
+
extra_headers=[("location", location), ("cache-control", "no-store")],
|
|
99
|
+
cookies=cookies,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _html(status: int, body: str) -> _Response:
|
|
104
|
+
return _Response(
|
|
105
|
+
status,
|
|
106
|
+
body.encode(),
|
|
107
|
+
content_type="text/html; charset=utf-8",
|
|
108
|
+
extra_headers=[("cache-control", "no-store")],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_NOT_CONFIGURED_PAGE = """<!doctype html>
|
|
113
|
+
<html lang="en">
|
|
114
|
+
<head>
|
|
115
|
+
<meta charset="utf-8" />
|
|
116
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
117
|
+
<title>Authentication not configured</title>
|
|
118
|
+
<style>
|
|
119
|
+
body { margin: 0; display: grid; place-items: center; min-height: 100vh;
|
|
120
|
+
background: #fafafa; color: #18181b;
|
|
121
|
+
font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
122
|
+
main { max-width: 28rem; padding: 2rem; text-align: center; }
|
|
123
|
+
h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
|
|
124
|
+
p { color: #52525b; font-size: .9rem; line-height: 1.5; margin: 0; }
|
|
125
|
+
</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<main>
|
|
129
|
+
<h1>Authentication is not configured yet</h1>
|
|
130
|
+
<p>
|
|
131
|
+
This app signs users in through Incorta, but its credentials have not
|
|
132
|
+
been provisioned. They are injected automatically — restarting the
|
|
133
|
+
app's dev server usually completes the setup.
|
|
134
|
+
</p>
|
|
135
|
+
</main>
|
|
136
|
+
</body>
|
|
137
|
+
</html>"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _signed_out_page(base_path: str) -> str:
|
|
141
|
+
sign_in_href = f"{base_path}/login?redirect_to=%2F"
|
|
142
|
+
return f"""<!doctype html>
|
|
143
|
+
<html lang="en">
|
|
144
|
+
<head>
|
|
145
|
+
<meta charset="utf-8" />
|
|
146
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
147
|
+
<title>Signed out</title>
|
|
148
|
+
<style>
|
|
149
|
+
body {{ margin: 0; display: grid; place-items: center; min-height: 100vh;
|
|
150
|
+
background: #fafafa; color: #18181b;
|
|
151
|
+
font-family: ui-sans-serif, system-ui, sans-serif; }}
|
|
152
|
+
main {{ max-width: 26rem; padding: 2rem; text-align: center; }}
|
|
153
|
+
h1 {{ font-size: 1.25rem; margin: 0 0 .5rem; }}
|
|
154
|
+
p {{ color: #52525b; font-size: .9rem; line-height: 1.5; margin: 0 0 1.25rem; }}
|
|
155
|
+
a {{ display: inline-block; padding: .55rem 1.1rem; border-radius: .5rem;
|
|
156
|
+
background: #18181b; color: #fafafa; font-size: .9rem;
|
|
157
|
+
text-decoration: none; }}
|
|
158
|
+
</style>
|
|
159
|
+
</head>
|
|
160
|
+
<body>
|
|
161
|
+
<main>
|
|
162
|
+
<h1>You’ve signed out of this app</h1>
|
|
163
|
+
<p>
|
|
164
|
+
Your Incorta account may still be signed in elsewhere — signing back
|
|
165
|
+
in here is one click.
|
|
166
|
+
</p>
|
|
167
|
+
<a href="{sign_in_href}">Sign in with Incorta</a>
|
|
168
|
+
</main>
|
|
169
|
+
</body>
|
|
170
|
+
</html>"""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _RequestFacts:
|
|
174
|
+
"""The request details the flow functions need, lifted out of the scope."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, scope: Scope) -> None:
|
|
177
|
+
self.method: str = scope.get("method", "GET").upper()
|
|
178
|
+
self.path: str = scope["path"]
|
|
179
|
+
self.query_string: str = (scope.get("query_string") or b"").decode("latin-1")
|
|
180
|
+
headers: dict[str, str] = {}
|
|
181
|
+
for raw_name, raw_value in scope.get("headers") or []:
|
|
182
|
+
name = raw_name.decode("latin-1").lower()
|
|
183
|
+
value = raw_value.decode("latin-1")
|
|
184
|
+
# Repeated headers are joined; "; " keeps multiple Cookie headers valid.
|
|
185
|
+
if name in headers:
|
|
186
|
+
separator = "; " if name == "cookie" else ", "
|
|
187
|
+
headers[name] = f"{headers[name]}{separator}{value}"
|
|
188
|
+
else:
|
|
189
|
+
headers[name] = value
|
|
190
|
+
self.headers = headers
|
|
191
|
+
self.scheme: str = scope.get("scheme", "http")
|
|
192
|
+
server = scope.get("server") or ("localhost", None)
|
|
193
|
+
port = f":{server[1]}" if server[1] is not None else ""
|
|
194
|
+
self._fallback_host = f"{server[0]}{port}"
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def cookie_header(self) -> str | None:
|
|
198
|
+
return self.headers.get("cookie")
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def query(self) -> dict[str, str]:
|
|
202
|
+
return dict(parse_qsl(self.query_string))
|
|
203
|
+
|
|
204
|
+
def origin(self, config: ResolvedConfig) -> str:
|
|
205
|
+
if config.app_url:
|
|
206
|
+
return config.app_url
|
|
207
|
+
forwarded_proto = self.headers.get("x-forwarded-proto")
|
|
208
|
+
proto = forwarded_proto.split(",")[0].strip() if forwarded_proto else self.scheme
|
|
209
|
+
forwarded_host = self.headers.get("x-forwarded-host")
|
|
210
|
+
host = (
|
|
211
|
+
forwarded_host.split(",")[0].strip()
|
|
212
|
+
if forwarded_host
|
|
213
|
+
else self.headers.get("host") or self._fallback_host
|
|
214
|
+
)
|
|
215
|
+
return f"{proto}://{host}"
|
|
216
|
+
|
|
217
|
+
def secure(self, config: ResolvedConfig) -> bool:
|
|
218
|
+
if config.secure_cookies != "auto":
|
|
219
|
+
return config.secure_cookies
|
|
220
|
+
return self.origin(config).startswith("https://")
|
|
221
|
+
|
|
222
|
+
def wants_html(self) -> bool:
|
|
223
|
+
return "text/html" in self.headers.get("accept", "")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class IncortaAuthMiddleware:
|
|
227
|
+
"""Wrap any ASGI app: ``IncortaAuthMiddleware(app)`` (config from
|
|
228
|
+
``INCORTA_*`` env vars) or pass ``resolve_config`` keyword arguments,
|
|
229
|
+
e.g. ``IncortaAuthMiddleware(app, cookie_same_site="none")``. With
|
|
230
|
+
Starlette/FastAPI: ``app.add_middleware(IncortaAuthMiddleware, ...)``."""
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
app: ASGIApp,
|
|
235
|
+
*,
|
|
236
|
+
auth: IncortaAuth | None = None,
|
|
237
|
+
public_paths: Sequence[str] = (),
|
|
238
|
+
**config_kwargs: Any,
|
|
239
|
+
) -> None:
|
|
240
|
+
self._app = app
|
|
241
|
+
self._auth = auth
|
|
242
|
+
self._public_paths = tuple(public_paths)
|
|
243
|
+
self._config_kwargs = config_kwargs
|
|
244
|
+
|
|
245
|
+
def _get_auth(self) -> IncortaAuth:
|
|
246
|
+
# Lazy on purpose: reads env at first request, never at import time —
|
|
247
|
+
# the platform may inject INCORTA_* after the process image is built.
|
|
248
|
+
if self._auth is None:
|
|
249
|
+
self._auth = IncortaAuth(**self._config_kwargs)
|
|
250
|
+
return self._auth
|
|
251
|
+
|
|
252
|
+
def _is_configured(self) -> bool:
|
|
253
|
+
return self._auth is not None or is_auth_configured(self._config_kwargs)
|
|
254
|
+
|
|
255
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
256
|
+
if scope["type"] == "websocket":
|
|
257
|
+
await self._handle_websocket(scope, receive, send)
|
|
258
|
+
return
|
|
259
|
+
if scope["type"] != "http":
|
|
260
|
+
await self._app(scope, receive, send)
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
facts = _RequestFacts(scope)
|
|
264
|
+
|
|
265
|
+
if facts.path in self._public_paths:
|
|
266
|
+
# Reachable before provisioning and without a session (liveness);
|
|
267
|
+
# a session is still attached when one exists.
|
|
268
|
+
if self._is_configured():
|
|
269
|
+
self._attach_session(scope, self._get_auth(), facts)
|
|
270
|
+
await self._app(scope, receive, send)
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
if not self._is_configured():
|
|
274
|
+
await _send(send, self._not_configured(facts))
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
auth = self._get_auth()
|
|
279
|
+
base_path = auth.config.base_path
|
|
280
|
+
|
|
281
|
+
if facts.path == base_path or facts.path.startswith(f"{base_path}/"):
|
|
282
|
+
loop = asyncio.get_running_loop()
|
|
283
|
+
response = await loop.run_in_executor(
|
|
284
|
+
None, functools.partial(self._handle_auth_route, auth, facts)
|
|
285
|
+
)
|
|
286
|
+
await _send(send, response)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
if self._attach_session(scope, auth, facts):
|
|
290
|
+
await self._app(scope, receive, send)
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
await _send(send, self._unauthenticated(auth, facts))
|
|
294
|
+
except Exception:
|
|
295
|
+
logger.exception("[incorta-auth] request handling failed")
|
|
296
|
+
await _send(send, _json(500, {"error": "internal_error"}))
|
|
297
|
+
|
|
298
|
+
def _attach_session(self, scope: Scope, auth: IncortaAuth, facts: _RequestFacts) -> bool:
|
|
299
|
+
session = auth.get_session(facts.cookie_header)
|
|
300
|
+
if session is None:
|
|
301
|
+
return False
|
|
302
|
+
scope.setdefault("state", {})["incorta_auth"] = session
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
def _unauthenticated(self, auth: IncortaAuth, facts: _RequestFacts) -> _Response:
|
|
306
|
+
if facts.method == "GET" and facts.wants_html():
|
|
307
|
+
redirect_to = facts.path + (f"?{facts.query_string}" if facts.query_string else "")
|
|
308
|
+
# Relative Location on purpose: this server sits behind a
|
|
309
|
+
# Host-rewriting proxy, so the request's own origin is an internal
|
|
310
|
+
# loopback address — only the BROWSER knows the public origin, and
|
|
311
|
+
# it resolves a relative redirect against it.
|
|
312
|
+
location = f"{auth.config.base_path}/login?redirect_to={quote(redirect_to, safe='')}"
|
|
313
|
+
return _Response(302, extra_headers=[("location", location)])
|
|
314
|
+
return _Response(
|
|
315
|
+
401,
|
|
316
|
+
json.dumps({"error": "unauthenticated"}).encode(),
|
|
317
|
+
content_type="application/json",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _not_configured(self, facts: _RequestFacts) -> _Response:
|
|
321
|
+
if not facts.wants_html():
|
|
322
|
+
return _Response(
|
|
323
|
+
503,
|
|
324
|
+
json.dumps({"error": "auth_not_configured"}).encode(),
|
|
325
|
+
content_type="application/json",
|
|
326
|
+
)
|
|
327
|
+
return _html(503, _NOT_CONFIGURED_PAGE)
|
|
328
|
+
|
|
329
|
+
async def _handle_websocket(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
330
|
+
# Websockets carry no redirect semantics — a missing session (or
|
|
331
|
+
# missing configuration) refuses the handshake outright.
|
|
332
|
+
if not self._is_configured():
|
|
333
|
+
await receive()
|
|
334
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
335
|
+
return
|
|
336
|
+
facts = _RequestFacts(scope)
|
|
337
|
+
if self._attach_session(scope, self._get_auth(), facts):
|
|
338
|
+
await self._app(scope, receive, send)
|
|
339
|
+
return
|
|
340
|
+
await receive()
|
|
341
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
342
|
+
|
|
343
|
+
# --- /auth/* routes (sync — runs in the executor) --------------------
|
|
344
|
+
|
|
345
|
+
def _handle_auth_route(self, auth: IncortaAuth, facts: _RequestFacts) -> _Response:
|
|
346
|
+
config = auth.config
|
|
347
|
+
route = facts.path[len(config.base_path) :] or "/"
|
|
348
|
+
method = facts.method
|
|
349
|
+
origin = facts.origin(config)
|
|
350
|
+
secure = facts.secure(config)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
if route == "/login" and method == "GET":
|
|
354
|
+
result = auth.begin_login(
|
|
355
|
+
origin=origin,
|
|
356
|
+
secure=secure,
|
|
357
|
+
cookie_header=facts.cookie_header,
|
|
358
|
+
redirect_to=facts.query.get("redirect_to"),
|
|
359
|
+
)
|
|
360
|
+
return _redirect(result.location, result.set_cookies)
|
|
361
|
+
|
|
362
|
+
if route == "/callback" and method == "GET":
|
|
363
|
+
callback = auth.complete_callback(
|
|
364
|
+
origin=origin,
|
|
365
|
+
secure=secure,
|
|
366
|
+
cookie_header=facts.cookie_header,
|
|
367
|
+
query=facts.query,
|
|
368
|
+
)
|
|
369
|
+
return _redirect(callback.location, callback.set_cookies)
|
|
370
|
+
|
|
371
|
+
if route == "/session" and method == "GET":
|
|
372
|
+
loaded = auth.load_session(cookie_header=facts.cookie_header, secure=secure)
|
|
373
|
+
if loaded.session is None:
|
|
374
|
+
return _json(200, {"user": None}, loaded.set_cookies)
|
|
375
|
+
return _json(
|
|
376
|
+
200,
|
|
377
|
+
{
|
|
378
|
+
"user": _user_to_payload(loaded.session.user),
|
|
379
|
+
"session": {
|
|
380
|
+
"expiresAt": loaded.session_expires_at,
|
|
381
|
+
"accessTokenExpiresAt": loaded.session.access_token_expires_at,
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
loaded.set_cookies,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if route == "/token" and method == "GET":
|
|
388
|
+
if not config.expose_access_token:
|
|
389
|
+
return _json(404, {"error": "not_found"})
|
|
390
|
+
loaded = auth.load_session(cookie_header=facts.cookie_header, secure=secure)
|
|
391
|
+
if loaded.session is None:
|
|
392
|
+
return _json(401, {"error": "unauthenticated"}, loaded.set_cookies)
|
|
393
|
+
return _json(
|
|
394
|
+
200,
|
|
395
|
+
{
|
|
396
|
+
"accessToken": loaded.session.access_token,
|
|
397
|
+
"expiresAt": loaded.session.access_token_expires_at,
|
|
398
|
+
},
|
|
399
|
+
loaded.set_cookies,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if route == "/logout" and method in ("GET", "POST"):
|
|
403
|
+
clear = auth.clear_session_cookie(secure=secure)
|
|
404
|
+
if method == "GET":
|
|
405
|
+
# No explicit redirect_to → the signed-out page, NOT "/":
|
|
406
|
+
# in an SSO-gated app, landing on a gated page silently
|
|
407
|
+
# re-runs the login flow and signs the user straight back
|
|
408
|
+
# in — sign-out must land somewhere UNGATED to stick.
|
|
409
|
+
raw = facts.query.get("redirect_to")
|
|
410
|
+
if not raw:
|
|
411
|
+
return _redirect(f"{config.base_path}/signed-out", [clear])
|
|
412
|
+
return _redirect(flow.validate_return_to(raw, origin), [clear])
|
|
413
|
+
return _json(200, {"ok": True}, [clear])
|
|
414
|
+
|
|
415
|
+
if route == "/signed-out" and method == "GET":
|
|
416
|
+
return _html(200, _signed_out_page(config.base_path))
|
|
417
|
+
|
|
418
|
+
return _json(404, {"error": "not_found"})
|
|
419
|
+
except IncortaAuthError as error:
|
|
420
|
+
logger.error("[incorta-auth] %s failed: %s", route, error)
|
|
421
|
+
return _json(500, {"error": error.code})
|
|
422
|
+
except Exception as error:
|
|
423
|
+
logger.error("[incorta-auth] %s failed: %s", route, error)
|
|
424
|
+
return _json(500, {"error": "internal_error"})
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def _send(send: Send, response: _Response) -> None:
|
|
428
|
+
await send(
|
|
429
|
+
{
|
|
430
|
+
"type": "http.response.start",
|
|
431
|
+
"status": response.status,
|
|
432
|
+
"headers": [
|
|
433
|
+
(name.encode("latin-1"), value.encode("latin-1"))
|
|
434
|
+
for name, value in response.headers
|
|
435
|
+
],
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
await send({"type": "http.response.body", "body": response.body})
|
incorta_auth/auth.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The main entry point — mirrors the TS SDK's ``createIncortaAuth``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config import AuthSession, ResolvedConfig, resolve_config
|
|
9
|
+
from .flow import (
|
|
10
|
+
CallbackResult,
|
|
11
|
+
LoadedSession,
|
|
12
|
+
LoginResult,
|
|
13
|
+
begin_login,
|
|
14
|
+
clear_session_cookie,
|
|
15
|
+
complete_callback,
|
|
16
|
+
get_session,
|
|
17
|
+
load_session,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IncortaAuth:
|
|
22
|
+
"""Bundles a resolved config with the framework-agnostic flow operations.
|
|
23
|
+
|
|
24
|
+
Framework adapters (ASGI middleware, the Streamlit gate) build on these;
|
|
25
|
+
they can also be called directly from any Python web stack.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: ResolvedConfig | None = None, **config_kwargs: Any) -> None:
|
|
29
|
+
self.config = config if config is not None else resolve_config(**config_kwargs)
|
|
30
|
+
|
|
31
|
+
def begin_login(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
origin: str,
|
|
35
|
+
secure: bool,
|
|
36
|
+
cookie_header: str | None = None,
|
|
37
|
+
redirect_to: str | None = None,
|
|
38
|
+
) -> LoginResult:
|
|
39
|
+
return begin_login(
|
|
40
|
+
self.config,
|
|
41
|
+
origin=origin,
|
|
42
|
+
secure=secure,
|
|
43
|
+
cookie_header=cookie_header,
|
|
44
|
+
redirect_to=redirect_to,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def complete_callback(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
origin: str,
|
|
51
|
+
secure: bool,
|
|
52
|
+
cookie_header: str | None,
|
|
53
|
+
query: Mapping[str, str],
|
|
54
|
+
) -> CallbackResult:
|
|
55
|
+
return complete_callback(
|
|
56
|
+
self.config, origin=origin, secure=secure, cookie_header=cookie_header, query=query
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def load_session(self, *, cookie_header: str | None, secure: bool) -> LoadedSession:
|
|
60
|
+
return load_session(self.config, cookie_header=cookie_header, secure=secure)
|
|
61
|
+
|
|
62
|
+
def get_session(self, cookie_header: str | None) -> AuthSession | None:
|
|
63
|
+
return get_session(self.config, cookie_header)
|
|
64
|
+
|
|
65
|
+
def clear_session_cookie(self, *, secure: bool) -> str:
|
|
66
|
+
return clear_session_cookie(self.config, secure)
|
|
67
|
+
|
|
68
|
+
def close(self) -> None:
|
|
69
|
+
self.config.close()
|