pyxle-auth 0.2.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.
pyxle_auth/__init__.py ADDED
@@ -0,0 +1,134 @@
1
+ """pyxle-auth — Django-grade authentication for Pyxle apps.
2
+
3
+ Public surface:
4
+
5
+ * :class:`AuthService` — sign up, sign in, resolve session, sign out,
6
+ password change/reset, email verification.
7
+ * :class:`AuthSettings` — knobs (password cost, session lifetime,
8
+ token TTLs, rate-limit buckets, cookie attributes). Load from env
9
+ via :meth:`AuthSettings.from_env`.
10
+ * :class:`User`, :class:`Session`, :class:`SessionInfo` — returned
11
+ dataclasses.
12
+ * :class:`SessionCookie` — helper carrying the cookie name/value plus
13
+ recommended attributes; expand with :meth:`SessionCookie.kwargs`.
14
+ * :class:`TokenService` / :class:`TokenClaim` — single-use,
15
+ purpose-scoped tokens (password reset, email verification, invites).
16
+ * :class:`ApiTokenService` / :class:`ApiToken` — long-lived, scoped
17
+ ``pyxle_pat_`` personal access tokens.
18
+ * :class:`RoleService` — roles, permissions, grants (RBAC).
19
+ * Guards — :func:`current_user`, :func:`require_user_page`,
20
+ :func:`require_user_action`, :func:`require_permission_page`,
21
+ :func:`require_permission_action`, :func:`bearer_token`.
22
+ * Errors: :class:`AuthError`, :class:`InvalidCredentials`,
23
+ :class:`RateLimited`, :class:`AccountExists`, :class:`WeakPassword`,
24
+ :class:`EmailNotVerified`, :class:`InvalidToken`,
25
+ :class:`RoleNotFound`, :class:`TokenLimitReached`.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pyxle_auth.api_tokens import (
31
+ TOKEN_PREFIX,
32
+ ApiToken,
33
+ ApiTokenService,
34
+ TokenLimitReached,
35
+ )
36
+ from pyxle_auth.errors import (
37
+ AccountExists,
38
+ AuthError,
39
+ EmailNotVerified,
40
+ InvalidCredentials,
41
+ InvalidToken,
42
+ RateLimited,
43
+ WeakPassword,
44
+ )
45
+ from pyxle_auth.guards import (
46
+ bearer_token,
47
+ current_user,
48
+ require_permission_action,
49
+ require_permission_page,
50
+ require_user_action,
51
+ require_user_page,
52
+ )
53
+ from pyxle_auth.models import Session, SessionCookie, SessionInfo, User
54
+ from pyxle_auth.rbac import RoleNotFound, RoleService
55
+ from pyxle_auth.service import AuthService
56
+ from pyxle_auth.settings import AuthSettings
57
+ from pyxle_auth.tokens import TokenClaim, TokenService
58
+
59
+
60
+ def get_auth_service() -> AuthService:
61
+ """Return the :class:`AuthService` the active ``pyxle-auth`` plugin set up.
62
+
63
+ Short form for app code::
64
+
65
+ from pyxle_auth import get_auth_service
66
+
67
+ @action
68
+ async def sign_in(request):
69
+ body = await request.json()
70
+ auth = get_auth_service()
71
+ user, cookie = await auth.sign_in(
72
+ email=body["email"], password=body["password"],
73
+ ip=request.client.host, user_agent=request.headers.get("user-agent", ""),
74
+ )
75
+ ...
76
+
77
+ Requires ``pyxle-auth`` to be listed in ``pyxle.config.json::plugins``.
78
+ Raises :class:`pyxle.plugins.PluginServiceError` otherwise.
79
+ """
80
+ from pyxle.plugins import plugin as _plugin
81
+
82
+ return _plugin("auth.service")
83
+
84
+
85
+ def get_auth_settings() -> AuthSettings:
86
+ """Return the :class:`AuthSettings` the active ``pyxle-auth`` plugin uses.
87
+
88
+ Useful for reading the cookie name / TTL / strict flag off the
89
+ same source of truth the service uses, e.g. when building a sign-out
90
+ response manually::
91
+
92
+ from pyxle_auth import get_auth_settings
93
+
94
+ settings = get_auth_settings()
95
+ response.delete_cookie(settings.cookie_name)
96
+ """
97
+ from pyxle.plugins import plugin as _plugin
98
+
99
+ return _plugin("auth.settings")
100
+
101
+
102
+ __all__ = [
103
+ "TOKEN_PREFIX",
104
+ "AccountExists",
105
+ "ApiToken",
106
+ "ApiTokenService",
107
+ "AuthError",
108
+ "AuthService",
109
+ "AuthSettings",
110
+ "EmailNotVerified",
111
+ "InvalidCredentials",
112
+ "InvalidToken",
113
+ "RateLimited",
114
+ "RoleNotFound",
115
+ "RoleService",
116
+ "Session",
117
+ "SessionCookie",
118
+ "SessionInfo",
119
+ "TokenClaim",
120
+ "TokenLimitReached",
121
+ "TokenService",
122
+ "User",
123
+ "WeakPassword",
124
+ "bearer_token",
125
+ "current_user",
126
+ "get_auth_service",
127
+ "get_auth_settings",
128
+ "require_permission_action",
129
+ "require_permission_page",
130
+ "require_user_action",
131
+ "require_user_page",
132
+ ]
133
+
134
+ __version__ = "0.2.0"
pyxle_auth/_ddl.py ADDED
@@ -0,0 +1,49 @@
1
+ """Dialect-aware DDL fragments shared by the services' ``ensure_schema()``.
2
+
3
+ The bundled migration files handle dialect differences with per-backend
4
+ overrides (``0001-pyxle-auth-core.mysql.sql``); the services' idempotent
5
+ ``ensure_schema()`` fallbacks build their DDL from these helpers so both
6
+ paths create identical schemas on every engine.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from pyxle_db import DatabaseLike
15
+
16
+ __all__ = ["ensure_index", "timestamp_type"]
17
+
18
+
19
+ def timestamp_type(dialect_name: str) -> str:
20
+ """The portable "datetime column" type for ``dialect_name``.
21
+
22
+ ``TIMESTAMP`` works everywhere except MySQL, where it is capped at
23
+ 2038 (configurable session/token expiries can exceed it), rounds to
24
+ whole seconds without an explicit precision, and is converted through
25
+ the session time zone. ``DATETIME(6)`` stores the UTC wall time
26
+ pyxle-db binds, byte for byte.
27
+ """
28
+ return "DATETIME(6)" if dialect_name == "mysql" else "TIMESTAMP"
29
+
30
+
31
+ async def ensure_index(db: DatabaseLike, *, name: str, table: str, columns: str) -> None:
32
+ """Create a secondary index if it doesn't exist, on any engine.
33
+
34
+ SQLite and PostgreSQL support ``CREATE INDEX IF NOT EXISTS``; MySQL
35
+ does not (error 1064), so there we probe ``information_schema`` for
36
+ the index first. ``name``/``table``/``columns`` are package-internal
37
+ constants, never user input.
38
+ """
39
+ if db.dialect.name == "mysql":
40
+ row = await db.fetchone(
41
+ "SELECT 1 FROM information_schema.statistics"
42
+ " WHERE table_schema = DATABASE() AND table_name = ?"
43
+ " AND index_name = ? LIMIT 1",
44
+ (table, name),
45
+ )
46
+ if row is None:
47
+ await db.execute(f"CREATE INDEX {name} ON {table} ({columns})")
48
+ return
49
+ await db.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({columns})")
@@ -0,0 +1,301 @@
1
+ """Long-lived, scoped API tokens (personal access tokens).
2
+
3
+ For programmatic access — CLIs, CI deploys, integrations:
4
+
5
+ * Format: ``pyxle_pat_<43 urlsafe chars>`` (256 bits of randomness). The
6
+ recognisable prefix lets secret scanners (and humans) identify leaks.
7
+ * Only the SHA-256 is stored. The raw token is returned exactly once from
8
+ :meth:`ApiTokenService.create`.
9
+ * Scopes are plain strings chosen by the app (``"deploy"``,
10
+ ``"projects:read"``). :meth:`resolve` checks membership; an app that
11
+ needs hierarchies can layer them on top.
12
+ * ``last_used_at`` is updated at most once per minute to keep reads cheap.
13
+
14
+ Schema::
15
+
16
+ api_tokens (
17
+ id TEXT PRIMARY KEY,
18
+ token_sha256 TEXT NOT NULL UNIQUE,
19
+ user_id TEXT NOT NULL,
20
+ name TEXT NOT NULL,
21
+ scopes TEXT NOT NULL, -- space-separated
22
+ created_at TIMESTAMP NOT NULL,
23
+ expires_at TIMESTAMP, -- NULL = non-expiring
24
+ last_used_at TIMESTAMP,
25
+ revoked_at TIMESTAMP
26
+ )
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import secrets
33
+ import uuid
34
+ from dataclasses import dataclass
35
+ from datetime import datetime, timedelta, timezone
36
+
37
+ from pyxle_db import DatabaseLike
38
+
39
+ from pyxle_auth._ddl import ensure_index, timestamp_type
40
+
41
+ __all__ = ["ApiToken", "ApiTokenService", "TokenLimitReached", "TOKEN_PREFIX"]
42
+
43
+ TOKEN_PREFIX = "pyxle_pat_"
44
+
45
+
46
+ _MAX_NAME_LENGTH = 100
47
+ _MAX_SCOPES = 32
48
+
49
+ _SCHEMA_TEMPLATE = """
50
+ CREATE TABLE IF NOT EXISTS api_tokens (
51
+ id VARCHAR(64) PRIMARY KEY,
52
+ token_sha256 VARCHAR(64) NOT NULL UNIQUE,
53
+ user_id VARCHAR(64) NOT NULL,
54
+ name TEXT NOT NULL,
55
+ scopes TEXT NOT NULL,
56
+ created_at {ts} NOT NULL,
57
+ expires_at {ts},
58
+ last_used_at {ts},
59
+ revoked_at {ts}
60
+ )
61
+ """
62
+
63
+
64
+
65
+ def _hash(raw: str) -> str:
66
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
67
+
68
+
69
+ def _utcnow() -> datetime:
70
+ return datetime.now(timezone.utc)
71
+
72
+
73
+ def _aware(value: datetime | None) -> datetime | None:
74
+ if value is None:
75
+ return None
76
+ if value.tzinfo is None:
77
+ return value.replace(tzinfo=timezone.utc)
78
+ return value
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class ApiToken:
83
+ """Token metadata — never contains the secret."""
84
+
85
+ id: str
86
+ user_id: str
87
+ name: str
88
+ scopes: tuple[str, ...]
89
+ created_at: datetime
90
+ expires_at: datetime | None
91
+ last_used_at: datetime | None
92
+ revoked_at: datetime | None
93
+
94
+ @property
95
+ def active(self) -> bool:
96
+ if self.revoked_at is not None:
97
+ return False
98
+ if self.expires_at is not None and self.expires_at <= _utcnow():
99
+ return False
100
+ return True
101
+
102
+ def has_scope(self, scope: str) -> bool:
103
+ return scope in self.scopes
104
+
105
+
106
+ def _validate_scopes(scopes: tuple[str, ...] | list[str]) -> tuple[str, ...]:
107
+ if not scopes:
108
+ raise ValueError("At least one scope is required")
109
+ if len(scopes) > _MAX_SCOPES:
110
+ raise ValueError(f"At most {_MAX_SCOPES} scopes per token")
111
+ cleaned: list[str] = []
112
+ for scope in scopes:
113
+ s = scope.strip()
114
+ if not s or " " in s or len(s) > 64:
115
+ raise ValueError(f"Invalid scope: {scope!r}")
116
+ if s not in cleaned:
117
+ cleaned.append(s)
118
+ return tuple(cleaned)
119
+
120
+
121
+ class ApiTokenService:
122
+ def __init__(self, db: DatabaseLike) -> None:
123
+ self._db = db
124
+
125
+ async def ensure_schema(self) -> None:
126
+ ts = timestamp_type(self._db.dialect.name)
127
+ await self._db.execute(_SCHEMA_TEMPLATE.format(ts=ts))
128
+ await ensure_index(
129
+ self._db, name="idx_api_tokens_user", table="api_tokens", columns="user_id"
130
+ )
131
+
132
+ async def create(
133
+ self,
134
+ *,
135
+ user_id: str,
136
+ name: str,
137
+ scopes: list[str] | tuple[str, ...],
138
+ expires_in_days: int | None = None,
139
+ max_tokens_per_user: int | None = None,
140
+ ) -> tuple[ApiToken, str]:
141
+ """Mint a token. Returns ``(metadata, raw_token)`` — the raw value
142
+ is shown once and never recoverable; store only the metadata.
143
+
144
+ ``max_tokens_per_user`` lets the app enforce a plan limit at the
145
+ same atomicity level as the insert (count + insert share the
146
+ transaction, so racing creates cannot exceed the cap).
147
+ """
148
+ if not user_id:
149
+ raise ValueError("user_id is required")
150
+ clean_name = (name or "").strip()
151
+ if not clean_name or len(clean_name) > _MAX_NAME_LENGTH:
152
+ raise ValueError(f"Token name must be 1–{_MAX_NAME_LENGTH} characters")
153
+ clean_scopes = _validate_scopes(tuple(scopes))
154
+ if expires_in_days is not None and expires_in_days <= 0:
155
+ raise ValueError("expires_in_days must be positive when given")
156
+
157
+ raw = TOKEN_PREFIX + secrets.token_urlsafe(32)
158
+ now = _utcnow()
159
+ expires_at = (
160
+ now + timedelta(days=expires_in_days) if expires_in_days else None
161
+ )
162
+ token = ApiToken(
163
+ id=uuid.uuid4().hex,
164
+ user_id=user_id,
165
+ name=clean_name,
166
+ scopes=clean_scopes,
167
+ created_at=now,
168
+ expires_at=expires_at,
169
+ last_used_at=None,
170
+ revoked_at=None,
171
+ )
172
+ async with self._db.transaction() as tx:
173
+ if max_tokens_per_user is not None:
174
+ # Serialize concurrent creates for THIS user before counting.
175
+ # SQLite already serializes all writes (BEGIN IMMEDIATE), but
176
+ # on PostgreSQL/MySQL two transactions could each COUNT the
177
+ # old total and both INSERT, slipping past the cap — so take a
178
+ # row lock on the owning user first. (Auth owns the users
179
+ # table, so the lock target always exists.)
180
+ if self._db.dialect.name in ("postgresql", "mysql"):
181
+ await tx.execute(
182
+ "SELECT id FROM users WHERE id = ? FOR UPDATE", (user_id,)
183
+ )
184
+ # Count only LIVE tokens — expired ones are dead weight and
185
+ # shouldn't block a user from minting a replacement.
186
+ row = await tx.fetchone(
187
+ "SELECT COUNT(*) AS n FROM api_tokens "
188
+ "WHERE user_id = ? AND revoked_at IS NULL "
189
+ "AND (expires_at IS NULL OR expires_at > ?)",
190
+ (user_id, now),
191
+ )
192
+ if row is not None and int(row["n"]) >= max_tokens_per_user:
193
+ raise TokenLimitReached(max_tokens_per_user)
194
+ await tx.execute(
195
+ "INSERT INTO api_tokens "
196
+ "(id, token_sha256, user_id, name, scopes, created_at, expires_at) "
197
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
198
+ (
199
+ token.id,
200
+ _hash(raw),
201
+ user_id,
202
+ clean_name,
203
+ " ".join(clean_scopes),
204
+ now,
205
+ expires_at,
206
+ ),
207
+ )
208
+ return token, raw
209
+
210
+ async def resolve(
211
+ self, *, raw_token: str, required_scope: str | None = None
212
+ ) -> ApiToken | None:
213
+ """Authenticate a presented token.
214
+
215
+ Returns the metadata when the token is known, unrevoked, unexpired,
216
+ and (when asked) carries ``required_scope``. Returns ``None`` for
217
+ every failure mode indistinguishably.
218
+ """
219
+ if (
220
+ not raw_token
221
+ or not isinstance(raw_token, str)
222
+ or not raw_token.startswith(TOKEN_PREFIX)
223
+ or len(raw_token) > 256
224
+ ):
225
+ return None
226
+ row = await self._db.fetchone(
227
+ "SELECT id, user_id, name, scopes, created_at, expires_at, "
228
+ "last_used_at, revoked_at FROM api_tokens WHERE token_sha256 = ?",
229
+ (_hash(raw_token),),
230
+ )
231
+ if row is None:
232
+ return None
233
+ token = _from_row(row)
234
+ if not token.active:
235
+ return None
236
+ if required_scope is not None and not token.has_scope(required_scope):
237
+ return None
238
+ await self._touch(token)
239
+ return token
240
+
241
+ async def _touch(self, token: ApiToken) -> None:
242
+ """Record use, throttled to once a minute."""
243
+ now = _utcnow()
244
+ if token.last_used_at is not None and (now - token.last_used_at) < timedelta(
245
+ minutes=1
246
+ ):
247
+ return
248
+ await self._db.execute(
249
+ "UPDATE api_tokens SET last_used_at = ? WHERE id = ?",
250
+ (now, token.id),
251
+ )
252
+
253
+ async def list_for_user(self, *, user_id: str) -> list[ApiToken]:
254
+ rows = await self._db.fetchall(
255
+ "SELECT id, user_id, name, scopes, created_at, expires_at, "
256
+ "last_used_at, revoked_at FROM api_tokens "
257
+ "WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC",
258
+ (user_id,),
259
+ )
260
+ return [_from_row(r) for r in rows]
261
+
262
+ async def revoke(self, *, user_id: str, token_id: str) -> bool:
263
+ """Revoke one token. Scoped by owner — a user can never revoke
264
+ another user's token, even with a leaked id. Idempotent."""
265
+ affected = 0
266
+ async with self._db.transaction() as tx:
267
+ affected = await tx.execute(
268
+ "UPDATE api_tokens SET revoked_at = ? "
269
+ "WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
270
+ (_utcnow(), token_id, user_id),
271
+ )
272
+ return affected > 0
273
+
274
+ async def revoke_all(self, *, user_id: str) -> int:
275
+ async with self._db.transaction() as tx:
276
+ return await tx.execute(
277
+ "UPDATE api_tokens SET revoked_at = ? "
278
+ "WHERE user_id = ? AND revoked_at IS NULL",
279
+ (_utcnow(), user_id),
280
+ )
281
+
282
+
283
+ class TokenLimitReached(Exception):
284
+ """The per-user token cap was hit (app-configured plan limit)."""
285
+
286
+ def __init__(self, limit: int) -> None:
287
+ super().__init__(f"API token limit reached ({limit}). Revoke one first.")
288
+ self.limit = limit
289
+
290
+
291
+ def _from_row(row: object) -> ApiToken:
292
+ return ApiToken(
293
+ id=row["id"], # type: ignore[index]
294
+ user_id=row["user_id"], # type: ignore[index]
295
+ name=row["name"], # type: ignore[index]
296
+ scopes=tuple((row["scopes"] or "").split()), # type: ignore[index]
297
+ created_at=_aware(row["created_at"]), # type: ignore[index,arg-type]
298
+ expires_at=_aware(row["expires_at"]), # type: ignore[index]
299
+ last_used_at=_aware(row["last_used_at"]), # type: ignore[index]
300
+ revoked_at=_aware(row["revoked_at"]), # type: ignore[index]
301
+ )
pyxle_auth/errors.py ADDED
@@ -0,0 +1,67 @@
1
+ """Structured error types for pyxle-auth.
2
+
3
+ These are the errors a request handler is expected to branch on. Every
4
+ other failure (DB down, clock jump, etc.) surfaces as a plain
5
+ :class:`AuthError`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class AuthError(Exception):
12
+ """Base class for every pyxle-auth error."""
13
+
14
+
15
+ class InvalidCredentials(AuthError):
16
+ """Email or password was wrong, or the user doesn't exist.
17
+
18
+ The message is deliberately indistinguishable between the two cases
19
+ to avoid user-enumeration leaks.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ super().__init__("Invalid email or password.")
24
+
25
+
26
+ class AccountExists(AuthError):
27
+ """Sign-up attempted with an email that already has an account."""
28
+
29
+ def __init__(self) -> None:
30
+ super().__init__("An account with this email already exists.")
31
+
32
+
33
+ class RateLimited(AuthError):
34
+ """The caller exceeded a rate-limit bucket.
35
+
36
+ ``retry_after_seconds`` is advisory — callers that want to return a
37
+ ``Retry-After`` header on 429 can read it.
38
+ """
39
+
40
+ def __init__(self, retry_after_seconds: int) -> None:
41
+ super().__init__(
42
+ f"Too many attempts. Try again in {retry_after_seconds} seconds."
43
+ )
44
+ self.retry_after_seconds = retry_after_seconds
45
+
46
+
47
+ class WeakPassword(AuthError):
48
+ """Sign-up or password-change rejected for a policy failure."""
49
+
50
+
51
+ class InvalidToken(AuthError):
52
+ """A single-use token (password reset, email verification) was
53
+ rejected — unknown, expired, already used, or minted for a different
54
+ purpose. The cases are deliberately indistinguishable so a probing
55
+ caller learns nothing from the error.
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ super().__init__("This link is invalid or has expired.")
60
+
61
+
62
+ class EmailNotVerified(AuthError):
63
+ """The account exists and the password matched, but the email
64
+ hasn't been verified and the service requires verification.
65
+
66
+ Raised only when ``AuthSettings.require_email_verified`` is set.
67
+ """