stackure 1.0.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.
- stackure/__init__.py +32 -0
- stackure/client.py +150 -0
- stackure/errors.py +26 -0
- stackure/middleware.py +148 -0
- stackure/py.typed +0 -0
- stackure/types.py +52 -0
- stackure/validation.py +30 -0
- stackure-1.0.0.dist-info/METADATA +141 -0
- stackure-1.0.0.dist-info/RECORD +11 -0
- stackure-1.0.0.dist-info/WHEEL +4 -0
- stackure-1.0.0.dist-info/licenses/LICENSE +21 -0
stackure/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Stackure Python SDK for authentication and session management.
|
|
2
|
+
|
|
3
|
+
Four public entry points:
|
|
4
|
+
|
|
5
|
+
* :func:`auth` — decorator that enforces authentication on a view
|
|
6
|
+
* :func:`verify` — non-throwing check that returns a :class:`VerifyResult`
|
|
7
|
+
* :func:`send_magic_link` — trigger a magic-link sign-in email
|
|
8
|
+
* :func:`logout` — revoke a session
|
|
9
|
+
|
|
10
|
+
Plus the supporting types :class:`User`, :class:`VerifyResult`,
|
|
11
|
+
:class:`MagicLinkResponse`, and the single error type :class:`StackureError`.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> import stackure
|
|
15
|
+
>>> await stackure.send_magic_link(email="user@example.com", app_id="...")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .client import logout, send_magic_link
|
|
19
|
+
from .errors import StackureError
|
|
20
|
+
from .middleware import auth, verify
|
|
21
|
+
from .types import MagicLinkResponse, User, VerifyResult
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"auth",
|
|
25
|
+
"verify",
|
|
26
|
+
"send_magic_link",
|
|
27
|
+
"logout",
|
|
28
|
+
"User",
|
|
29
|
+
"VerifyResult",
|
|
30
|
+
"MagicLinkResponse",
|
|
31
|
+
"StackureError",
|
|
32
|
+
]
|
stackure/client.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Internal HTTP layer for the Stackure SDK.
|
|
2
|
+
|
|
3
|
+
Consumers should use the module-level ``send_magic_link`` and ``logout``
|
|
4
|
+
functions re-exported from ``stackure``; they go through this layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .errors import StackureError
|
|
13
|
+
from .types import MagicLinkResponse, User
|
|
14
|
+
from .validation import validate_email, validate_uuid
|
|
15
|
+
|
|
16
|
+
_DEFAULT_BASE_URL = "https://stackure.com"
|
|
17
|
+
_REQUEST_TIMEOUT_S = 10.0
|
|
18
|
+
_MAX_RETRIES = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _base_url() -> str:
|
|
22
|
+
"""Resolve the base URL from ``STACKURE_BASE_URL`` or fall back to production."""
|
|
23
|
+
env = os.environ.get("STACKURE_BASE_URL")
|
|
24
|
+
return env.rstrip("/") if env else _DEFAULT_BASE_URL
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
|
28
|
+
"""Perform an HTTP request with retry + timeout.
|
|
29
|
+
|
|
30
|
+
Retries 5xx responses twice with exponential backoff (500ms, 1s). Timeouts
|
|
31
|
+
are never retried — a second attempt would obscure real latency.
|
|
32
|
+
"""
|
|
33
|
+
url = f"{_base_url()}{path}"
|
|
34
|
+
last_error: Exception | None = None
|
|
35
|
+
|
|
36
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
37
|
+
if attempt > 0:
|
|
38
|
+
await asyncio.sleep(0.5 * (2 ** (attempt - 1)))
|
|
39
|
+
try:
|
|
40
|
+
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT_S) as http_client:
|
|
41
|
+
response = await http_client.request(method, url, **kwargs)
|
|
42
|
+
if response.status_code >= 500 and attempt < _MAX_RETRIES:
|
|
43
|
+
last_error = StackureError(
|
|
44
|
+
"network",
|
|
45
|
+
f"Server error ({response.status_code})",
|
|
46
|
+
response.status_code,
|
|
47
|
+
)
|
|
48
|
+
continue
|
|
49
|
+
return response
|
|
50
|
+
except httpx.TimeoutException as exc:
|
|
51
|
+
raise StackureError(
|
|
52
|
+
"timeout",
|
|
53
|
+
f"Request timed out after {_REQUEST_TIMEOUT_S}s",
|
|
54
|
+
) from exc
|
|
55
|
+
except httpx.RequestError as exc:
|
|
56
|
+
last_error = StackureError("network", f"Network request failed: {exc}")
|
|
57
|
+
|
|
58
|
+
raise last_error or StackureError("network", "Request failed after retries")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _handle_response(response: httpx.Response) -> dict:
|
|
62
|
+
"""Return parsed JSON or raise a typed :class:`StackureError` for non-2xx."""
|
|
63
|
+
if not response.is_success:
|
|
64
|
+
try:
|
|
65
|
+
error_text = response.text
|
|
66
|
+
except Exception:
|
|
67
|
+
error_text = "unknown error"
|
|
68
|
+
if response.status_code == 401:
|
|
69
|
+
raise StackureError("auth", error_text or "Authentication failed", 401)
|
|
70
|
+
if response.status_code == 403:
|
|
71
|
+
raise StackureError("forbidden", error_text or "Access forbidden", 403)
|
|
72
|
+
raise StackureError(
|
|
73
|
+
"network",
|
|
74
|
+
f"API error ({response.status_code}): {error_text}",
|
|
75
|
+
response.status_code,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
return response.json()
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
raise StackureError("network", "Invalid JSON response from server") from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def send_magic_link(email: str, app_id: str | None = None) -> MagicLinkResponse:
|
|
84
|
+
"""Send a passwordless sign-in email to a user.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
email: Recipient's email address.
|
|
88
|
+
app_id: Your Stackure application UUID. Optional.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
:class:`MagicLinkResponse` with the API's confirmation message.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
StackureError: With ``code`` in ``{"validation", "network", "timeout", "auth"}``.
|
|
95
|
+
"""
|
|
96
|
+
validate_email(email)
|
|
97
|
+
if app_id:
|
|
98
|
+
validate_uuid(app_id, "App ID")
|
|
99
|
+
body: dict = {"user_email": email}
|
|
100
|
+
if app_id:
|
|
101
|
+
body["app_id"] = app_id
|
|
102
|
+
response = await _request("POST", "/api/public/auth/magic-link/send", json=body)
|
|
103
|
+
data = _handle_response(response)
|
|
104
|
+
try:
|
|
105
|
+
return MagicLinkResponse(message=data["message"])
|
|
106
|
+
except KeyError as exc:
|
|
107
|
+
raise StackureError("network", "Unexpected API response format") from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _validate_session(app_id: str, cookies: dict | None = None) -> dict:
|
|
111
|
+
"""Internal helper: validate a session and return the raw API response dict."""
|
|
112
|
+
validate_uuid(app_id, "App ID")
|
|
113
|
+
response = await _request(
|
|
114
|
+
"GET",
|
|
115
|
+
"/api/public/auth/session/validate",
|
|
116
|
+
params={"app_id": app_id},
|
|
117
|
+
cookies=cookies,
|
|
118
|
+
)
|
|
119
|
+
data = _handle_response(response)
|
|
120
|
+
user = None
|
|
121
|
+
if data.get("user"):
|
|
122
|
+
u = data["user"]
|
|
123
|
+
try:
|
|
124
|
+
user = User(
|
|
125
|
+
user_id=u["user_id"],
|
|
126
|
+
user_email=u["user_email"],
|
|
127
|
+
user_first_name=u["user_first_name"],
|
|
128
|
+
user_last_name=u["user_last_name"],
|
|
129
|
+
user_roles=u.get("user_roles", []),
|
|
130
|
+
)
|
|
131
|
+
except KeyError as exc:
|
|
132
|
+
raise StackureError("network", "Unexpected user payload format") from exc
|
|
133
|
+
return {
|
|
134
|
+
"authenticated": data.get("authenticated", False),
|
|
135
|
+
"user": user,
|
|
136
|
+
"sign_in_url": data.get("sign_in_url"),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def logout(cookies: dict | None = None) -> None:
|
|
141
|
+
"""Revoke the session represented by ``cookies``.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
cookies: Cookies from the incoming HTTP request.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
StackureError: With ``code`` in ``{"network", "timeout"}``.
|
|
148
|
+
"""
|
|
149
|
+
response = await _request("POST", "/api/public/auth/sign-out", cookies=cookies)
|
|
150
|
+
_handle_response(response)
|
stackure/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Stackure SDK error type."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StackureError(Exception):
|
|
5
|
+
"""The single exception type raised by every SDK function.
|
|
6
|
+
|
|
7
|
+
Catch once and inspect ``code`` to branch on category.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
code: One of ``"validation"``, ``"auth"``, ``"forbidden"``, ``"timeout"``,
|
|
11
|
+
``"network"``.
|
|
12
|
+
status_code: HTTP status returned by the API, or ``None`` if the error
|
|
13
|
+
happened before a response was received.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> try:
|
|
17
|
+
... await send_magic_link(email="bad")
|
|
18
|
+
... except StackureError as err:
|
|
19
|
+
... if err.code == "validation":
|
|
20
|
+
... ...
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, code: str, message: str, status_code: int | None = None):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.code = code
|
|
26
|
+
self.status_code = status_code
|
stackure/middleware.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Authentication verification and decorator for web frameworks."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .client import _validate_session
|
|
11
|
+
from .errors import StackureError
|
|
12
|
+
from .types import VerifyResult
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def verify(
|
|
18
|
+
app_id: str,
|
|
19
|
+
cookies: dict | None = None,
|
|
20
|
+
roles: list[str] | None = None,
|
|
21
|
+
) -> VerifyResult:
|
|
22
|
+
"""Check whether ``cookies`` hold a valid session for ``app_id``.
|
|
23
|
+
|
|
24
|
+
Returns a :class:`VerifyResult` without raising; callers inspect the
|
|
25
|
+
result and decide how to respond.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
app_id: Your Stackure application UUID.
|
|
29
|
+
cookies: Session cookies from the incoming HTTP request.
|
|
30
|
+
roles: Optional required roles. The user must hold at least one.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
>>> result = await verify(app_id="...", cookies=dict(request.cookies))
|
|
34
|
+
>>> if not result.authenticated:
|
|
35
|
+
... return {"error": result.error["message"]}, result.error["code"]
|
|
36
|
+
>>> return {"user": result.user}
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
session = await _validate_session(app_id, cookies)
|
|
40
|
+
if not session["authenticated"] or not session["user"]:
|
|
41
|
+
return VerifyResult(
|
|
42
|
+
authenticated=False,
|
|
43
|
+
error={
|
|
44
|
+
"code": 401,
|
|
45
|
+
"message": "Valid authentication required",
|
|
46
|
+
"sign_in_url": session["sign_in_url"],
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
user = session["user"]
|
|
50
|
+
if roles and not any(role in user.user_roles for role in roles):
|
|
51
|
+
return VerifyResult(
|
|
52
|
+
authenticated=False,
|
|
53
|
+
user=user,
|
|
54
|
+
error={"code": 403, "message": f"Requires one of: {', '.join(roles)}"},
|
|
55
|
+
)
|
|
56
|
+
return VerifyResult(authenticated=True, user=user)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
_logger.error("stackure: verification error: %s", e)
|
|
59
|
+
return VerifyResult(
|
|
60
|
+
authenticated=False,
|
|
61
|
+
error={"code": 500, "message": "Authentication verification failed"},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_cookies(request: Any) -> dict:
|
|
66
|
+
"""Pull cookies off any framework's request object.
|
|
67
|
+
|
|
68
|
+
Works with FastAPI, Starlette, Flask, aiohttp (``cookies`` attr) and
|
|
69
|
+
Django (``COOKIES`` attr). Returns an empty dict if nothing is found.
|
|
70
|
+
"""
|
|
71
|
+
if request is None:
|
|
72
|
+
return {}
|
|
73
|
+
cookies = getattr(request, "cookies", None) or getattr(request, "COOKIES", None)
|
|
74
|
+
return dict(cookies) if cookies else {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def auth(app_id: str, roles: list[str] | None = None) -> Callable:
|
|
78
|
+
"""Decorator enforcing authentication on a view.
|
|
79
|
+
|
|
80
|
+
On success, attaches the authenticated :class:`~stackure.User` to
|
|
81
|
+
``request.user`` (when the request object accepts attribute assignment).
|
|
82
|
+
On failure, raises :class:`~stackure.StackureError` — your framework's
|
|
83
|
+
exception handling turns it into the appropriate HTTP response.
|
|
84
|
+
|
|
85
|
+
Supports both synchronous and asynchronous view functions.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
app_id: Your Stackure application UUID.
|
|
89
|
+
roles: Optional required roles.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
>>> @app.get("/dashboard")
|
|
93
|
+
... @auth(app_id="your-app-id", roles=["admin"])
|
|
94
|
+
... async def dashboard(request):
|
|
95
|
+
... return {"user": request.user}
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def decorator(func: Callable) -> Callable:
|
|
99
|
+
if inspect.iscoroutinefunction(func):
|
|
100
|
+
|
|
101
|
+
@functools.wraps(func)
|
|
102
|
+
async def _async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
103
|
+
request = args[0] if args else kwargs.get("request")
|
|
104
|
+
result = await verify(
|
|
105
|
+
app_id,
|
|
106
|
+
cookies=_extract_cookies(request),
|
|
107
|
+
roles=roles,
|
|
108
|
+
)
|
|
109
|
+
if not result.authenticated:
|
|
110
|
+
_raise(result)
|
|
111
|
+
try:
|
|
112
|
+
request.user = result.user
|
|
113
|
+
except (AttributeError, TypeError):
|
|
114
|
+
pass
|
|
115
|
+
return await func(*args, **kwargs)
|
|
116
|
+
|
|
117
|
+
return _async_wrapper
|
|
118
|
+
|
|
119
|
+
@functools.wraps(func)
|
|
120
|
+
def _sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
121
|
+
request = args[0] if args else kwargs.get("request")
|
|
122
|
+
loop = asyncio.new_event_loop()
|
|
123
|
+
try:
|
|
124
|
+
result = loop.run_until_complete(
|
|
125
|
+
verify(app_id, cookies=_extract_cookies(request), roles=roles),
|
|
126
|
+
)
|
|
127
|
+
finally:
|
|
128
|
+
loop.close()
|
|
129
|
+
if not result.authenticated:
|
|
130
|
+
_raise(result)
|
|
131
|
+
try:
|
|
132
|
+
request.user = result.user
|
|
133
|
+
except (AttributeError, TypeError):
|
|
134
|
+
pass
|
|
135
|
+
return func(*args, **kwargs)
|
|
136
|
+
|
|
137
|
+
return _sync_wrapper
|
|
138
|
+
|
|
139
|
+
return decorator
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _raise(result: VerifyResult) -> None:
|
|
143
|
+
"""Translate a failed :class:`VerifyResult` into a :class:`StackureError`."""
|
|
144
|
+
code = result.error["code"] if result.error else 401
|
|
145
|
+
message = result.error["message"] if result.error else "Authentication required"
|
|
146
|
+
if code == 403:
|
|
147
|
+
raise StackureError("forbidden", message, 403)
|
|
148
|
+
raise StackureError("auth", message, code)
|
stackure/py.typed
ADDED
|
File without changes
|
stackure/types.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Data types returned by the Stackure SDK."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class User:
|
|
8
|
+
"""An authenticated Stackure user.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
user_id: Unique identifier for the user.
|
|
12
|
+
user_email: User's email address.
|
|
13
|
+
user_first_name: User's first name.
|
|
14
|
+
user_last_name: User's last name.
|
|
15
|
+
user_roles: Role names assigned to the user for the current app.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
user_id: str
|
|
19
|
+
user_email: str
|
|
20
|
+
user_first_name: str
|
|
21
|
+
user_last_name: str
|
|
22
|
+
user_roles: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MagicLinkResponse:
|
|
27
|
+
"""Successful :func:`send_magic_link` response.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
message: Human-readable confirmation string from the API.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
message: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class VerifyResult:
|
|
38
|
+
"""Outcome of a :func:`verify` call.
|
|
39
|
+
|
|
40
|
+
Exactly one of ``user`` or ``error`` is populated depending on
|
|
41
|
+
``authenticated``.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
authenticated: Whether the request carries a valid session.
|
|
45
|
+
user: Authenticated user when ``authenticated`` is ``True``.
|
|
46
|
+
error: Dict with ``code`` (int), ``message`` (str), and optional
|
|
47
|
+
``sign_in_url`` when ``authenticated`` is ``False``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
authenticated: bool
|
|
51
|
+
user: User | None = None
|
|
52
|
+
error: dict | None = None
|
stackure/validation.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Input validation utilities for the Stackure SDK."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .errors import StackureError
|
|
6
|
+
|
|
7
|
+
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
|
8
|
+
_UUID_RE = re.compile(
|
|
9
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
|
10
|
+
re.IGNORECASE,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_email(email: str) -> None:
|
|
15
|
+
"""Raise a ``"validation"``-coded :class:`StackureError` if ``email`` is malformed."""
|
|
16
|
+
if not email or not isinstance(email, str):
|
|
17
|
+
raise StackureError("validation", "email is required")
|
|
18
|
+
if not _EMAIL_RE.match(email):
|
|
19
|
+
raise StackureError("validation", "invalid email format")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_uuid(value: str, field_name: str = "UUID") -> None:
|
|
23
|
+
"""Raise a ``"validation"``-coded :class:`StackureError` if ``value`` isn't a UUID v4."""
|
|
24
|
+
if not value or not isinstance(value, str):
|
|
25
|
+
raise StackureError("validation", f"{field_name} is required")
|
|
26
|
+
if not _UUID_RE.match(value):
|
|
27
|
+
raise StackureError(
|
|
28
|
+
"validation",
|
|
29
|
+
f"invalid {field_name} format (must be a valid UUID)",
|
|
30
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stackure
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Stackure authentication SDK for Python
|
|
5
|
+
Project-URL: Homepage, https://stackure.com
|
|
6
|
+
Project-URL: Repository, https://github.com/syi-stackure/sdk-py
|
|
7
|
+
Project-URL: Documentation, https://docs.stackure.com
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/syi-stackure/sdk-py/issues
|
|
9
|
+
Author-email: Stackure <support@stackure.com>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2025 ysashank
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: auth,authentication,magic-link,passwordless,sdk,sso,stackure,verify
|
|
33
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
42
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
43
|
+
Classifier: Typing :: Typed
|
|
44
|
+
Requires-Python: >=3.10
|
|
45
|
+
Requires-Dist: httpx
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# Stackure Python SDK
|
|
49
|
+
|
|
50
|
+
[](https://github.com/syi-stackure/sdk-py/actions/workflows/check-build.yml)
|
|
51
|
+
[](https://pypi.org/project/stackure/)
|
|
52
|
+
[](https://pypi.org/project/stackure/)
|
|
53
|
+
[](https://pypi.org/project/stackure/)
|
|
54
|
+
[](https://docs.pypi.org/trusted-publishers/)
|
|
55
|
+
[](./LICENSE)
|
|
56
|
+
|
|
57
|
+
Authentication for your app. One decorator.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install stackure
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Requires Python 3.10+.
|
|
66
|
+
|
|
67
|
+
## Protect a route
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from stackure import auth
|
|
71
|
+
|
|
72
|
+
@app.get("/admin")
|
|
73
|
+
@auth(app_id="my-app-id", roles=["admin"])
|
|
74
|
+
async def admin(request):
|
|
75
|
+
return {"user": request.user}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Works with FastAPI, Starlette, Django, Flask, aiohttp — cookies extracted automatically from the request object.
|
|
79
|
+
|
|
80
|
+
## Verify manually
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from stackure import verify
|
|
84
|
+
|
|
85
|
+
result = await verify(app_id="my-app-id", cookies=dict(request.cookies))
|
|
86
|
+
|
|
87
|
+
if not result.authenticated:
|
|
88
|
+
return {"error": result.error["message"]}, result.error["code"]
|
|
89
|
+
|
|
90
|
+
return {"user": result.user}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Send a magic link
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from stackure import send_magic_link
|
|
97
|
+
|
|
98
|
+
await send_magic_link(email="user@example.com", app_id="my-app-id")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Log out
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from stackure import logout
|
|
105
|
+
|
|
106
|
+
await logout(dict(request.cookies))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
Set `STACKURE_BASE_URL` to point at a non-production environment:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
STACKURE_BASE_URL=https://stage.stackure.com python app.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Errors
|
|
118
|
+
|
|
119
|
+
All errors are `StackureError`. Switch on `.code`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from stackure import StackureError
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
await send_magic_link(email=email)
|
|
126
|
+
except StackureError as err:
|
|
127
|
+
# err.code is one of: "validation" | "auth" | "forbidden" | "timeout" | "network"
|
|
128
|
+
...
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
Open a PR. Tag a release when ready: `git tag vX.Y.Z && git push --tags` — the release workflow builds, signs, and publishes.
|
|
134
|
+
|
|
135
|
+
## Security
|
|
136
|
+
|
|
137
|
+
Report vulnerabilities via [GitHub Security Advisories](https://github.com/syi-stackure/sdk-py/security/advisories/new). Releases publish to PyPI via [OIDC trusted publishing](https://docs.pypi.org/trusted-publishers/) with [GitHub build-provenance attestations](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds).
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
stackure/__init__.py,sha256=iWvXzcYR9EYzqYbsTg0p5sxDiq6Fw46apNINTmE6Y5k,931
|
|
2
|
+
stackure/client.py,sha256=AltOmTvTLzdEnokbstPyisYTMTwoBJ-cjKtcoYLJNLI,5225
|
|
3
|
+
stackure/errors.py,sha256=d_0bpAQXfsDZeKv-pTCKZw-PUv-W2jSWH0tJKS336BM,827
|
|
4
|
+
stackure/middleware.py,sha256=pjNx65AuQOAe7cUFYo3bEelC1eAAh3RDIGAchIcHVxE,5190
|
|
5
|
+
stackure/types.py,sha256=WSahy_tMhQbAyTgGRK8XwUmpQo0m5MsVemSr-4f6LJE,1319
|
|
6
|
+
stackure/validation.py,sha256=yjOvrg9ljZuNKWCzy-RfR4FKn0iDBYhc8yOikAmwB9U,1069
|
|
7
|
+
stackure/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
stackure-1.0.0.dist-info/METADATA,sha256=IOtVa0uO1zfufp-UwdeKSnQrBbV-z4DMAl0iVoEfHaY,4978
|
|
9
|
+
stackure-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
stackure-1.0.0.dist-info/licenses/LICENSE,sha256=lcI5jP8yVxAC9CwlzJQi0f0mHv3Xy9ku_iJ-L8pU6ZY,1065
|
|
11
|
+
stackure-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ysashank
|
|
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.
|