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.
@@ -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&rsquo;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()