diff --git a/packages/pyxle-auth/CHANGELOG.md b/packages/pyxle-auth/CHANGELOG.md index d2cc176..7e50765 100644 --- a/packages/pyxle-auth/CHANGELOG.md +++ b/packages/pyxle-auth/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to `pyxle-auth` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-06-19 + +### Added + +- **Flexible identity — sign in with a username, not just an email.** A new + `identifier` setting picks how accounts are identified: `"email"` (the + default — every existing app is unchanged) or `"username"`. In username mode + `sign_up` / `sign_in` take a `username`, no email is required, and the + email-only flows (verification, password reset) simply go unused. The + schema now carries both `email` and `username` as nullable, UNIQUE columns, + so an app can also collect an optional email alongside a username. + - **Username policy, all configurable:** length (`usernameMinLength` / + `usernameMaxLength`, default 3–30), an allowed-character `usernamePattern` + (default `^[a-z0-9_-]+$`), and a **reserved-name block-list** (≈90 names — + system/staff identities and common routes like `/login`, `/api`, + `/settings` — to prevent impersonation and route collisions). Usernames are + lowercase-normalised, so uniqueness is case-insensitive on every backend. + - **`AuthService.username_available(name)`** and a public + `GET {authPathPrefix}/username-available?u=` endpoint (username mode). A + handle's availability is intentionally discoverable — a user must know + whether it's free before claiming it — while sign-in stays enumeration-safe + (a missing/malformed handle is an ordinary `InvalidCredentials`). + - The credential endpoints (`/login`, `/signup`, JWT `/token`), the + `window.__PYXLE_AUTH__` SSR seed, and `useAuth()` use the configured field; + the seed publishes the `identifier` mode so the client renders the right + input. `user.username` is included in `request.user` and `/me`. +- **Per-identifier rate limiting.** The sign-in limiter keys on the configured + identifier (email or username) instead of email specifically. + +### Changed + +- `users.email` is now **nullable** (was `NOT NULL`) and `users.username` is + added (nullable, UNIQUE). Migration `0004-flexible-identity` applies this in + place on PostgreSQL/MySQL and via a session-preserving table rebuild on + SQLite — **no login is lost**. Backward compatible: existing email-mode apps + need no changes and `email` keeps its UNIQUE constraint. +- `User.email` is now `str | None` and `User` gains `username: str | None`. + ## [0.3.0] - 2026-06-17 ### Added diff --git a/packages/pyxle-auth/README.md b/packages/pyxle-auth/README.md index e54ab6f..7e21150 100644 --- a/packages/pyxle-auth/README.md +++ b/packages/pyxle-auth/README.md @@ -6,6 +6,10 @@ permissions, API tokens, and one-line request guards. Built on [pyxle-db](https://github.com/pyxle-dev/pyxle-plugins/tree/main/packages/pyxle-db), so the same code runs on SQLite, PostgreSQL, and MySQL. (Caveat: every query is portable across all three, but the *shipped schema files* target SQLite and PostgreSQL; MySQL needs a dialect-override migration — `0001-pyxle-auth-core.mysql.sql` — because MySQL requires key lengths on TEXT keys. On the roadmap; contributions welcome.) +- **Email *or* username identity** — set `identifier` to `"email"` (default) + or `"username"`. Username mode needs no email: pick any available handle, + case-insensitive and reserved-name guarded, with a `/username-available` + check endpoint. (More below.) - **Sessions** — argon2id-hashed passwords, server-side sessions with sliding expiry and an absolute cap, `HttpOnly; Secure; SameSite=Lax` cookies. @@ -100,6 +104,50 @@ async def endpoint(request: Request) -> JSONResponse: `sign_up` has the same shape. `sign_out(cookie_value=...)` returns a cookie that clears the browser's copy — set it the same way. +## Identity model: email or username + +By default users are identified by **email**, exactly as before. To build a +username-based app — pick any available handle, no email or phone — set +`identifier` to `"username"` in `pyxle.config.json`: + +```json +{ + "plugins": [ + { "name": "pyxle-db", "settings": { "path": "data/app.db" } }, + { "name": "pyxle-auth", "settings": { "identifier": "username" } } + ] +} +``` + +Now the credential endpoints, the `useAuth()` hook, and the services take a +`username` instead of an `email`: + +```python +user, cookie = await auth.sign_up(username="ada", password="…") # email optional +user = await auth.verify_credentials(username="ADA", password="…") # case-insensitive +free = await auth.username_available("ada") # False +``` + +- **Normalisation:** usernames are trimmed and lowercased, so uniqueness is + case-insensitive on every backend (`Ada` == `ada`). +- **Policy (configurable):** `usernameMinLength` / `usernameMaxLength` + (default 3–30), `usernamePattern` (default `^[a-z0-9_-]+$`), and a + reserved-name block-list (≈90 system/route names like `admin`, `api`, + `login` — override or clear it in code). +- **Availability is public** (but **per-IP rate-limited**, so it can't be used to + bulk-scrape the user list): `GET {authPathPrefix}/username-available?u=` + returns `{"available": true|false}` so a picker can show it live. Sign-in + stays enumeration-safe — a missing or malformed handle is just an + `InvalidCredentials`, never a distinct error. +- **Optional email:** username mode may still accept an email at sign-up + (e.g. for a future reset) — pass both; only the configured identifier is + required. +- **Multiple accounts:** there's no per-person limit — anyone can register as + many usernames as they like (tune `rateLimitSignUpPerHour` to taste). + +`email` and `username` are both nullable, UNIQUE columns, so switching modes +is a config change, not a code rewrite. Existing email apps are untouched. + ## Bring your own mailer pyxle-auth never sends email. Flows that need delivery return a raw, @@ -235,8 +283,14 @@ Precedence: plugin `settings` in `pyxle.config.json` **>** | `emailVerifyTtlSeconds` | `PYXLE_AUTH_EMAIL_VERIFY_TTL_SECONDS` | `86400` (24 h) | Verify-token lifetime | | `rateLimitSignInPerHour` | `PYXLE_AUTH_RL_SIGN_IN_PER_HOUR` | `10` | Per IP and per email | | `rateLimitSignUpPerHour` | `PYXLE_AUTH_RL_SIGN_UP_PER_HOUR` | `5` | Per IP | +| `rateLimitUsernameCheckPerHour` | `PYXLE_AUTH_RL_USERNAME_CHECK_PER_HOUR` | `120` | Per IP, on `/username-available` | | `rateLimitPasswordResetPerHour` | `PYXLE_AUTH_RATE_LIMIT_PASSWORD_RESET_PER_HOUR` | `3` | Per email and per IP | | `requireEmailVerified` | `PYXLE_AUTH_REQUIRE_VERIFIED` | `false` | Gate sign-in on verification | +| `identifier` | `PYXLE_AUTH_IDENTIFIER` | `email` | Login identity: `email` or `username` | +| `usernameMinLength` | `PYXLE_AUTH_USERNAME_MIN` | `3` | Min username length (username mode) | +| `usernameMaxLength` | `PYXLE_AUTH_USERNAME_MAX` | `30` | Max username length (username mode) | +| `usernamePattern` | — | `^[a-z0-9_-]+$` | Allowed characters (matched after lowercasing) | +| `usernameReserved` | — | ≈90 names | Reserved-username block-list (override in code) | | `strict` | — | `true` | Enforce `cookieSecure=true`; set `false` for HTTP dev servers | Outside the plugin, load the same configuration with @@ -246,22 +300,30 @@ test suites — it drops argon costs and TTLs so suites stay fast. ## Schema The plugin owns its tables (`users`, `sessions`, `auth_tokens`, -`api_tokens`, `roles`, `user_roles`, `ratelimit_buckets`): bundled -migrations are applied through `pyxle_db.Migrator` at startup, followed -by each service's idempotent `ensure_schema()`. Repeated startups are -no-ops. The SQL is portable qmark style throughout, so the plugin works +`api_tokens`, `roles`, `user_roles`, `ratelimit_buckets`, plus +`oauth_identities` and `jwt_refresh_tokens` when those features are on): +bundled migrations are applied through `pyxle_db.Migrator` at startup, +followed by each service's idempotent `ensure_schema()`. Repeated startups +are no-ops. The SQL is portable qmark style throughout, so the plugin works on every pyxle-db backend without per-database configuration. +`users.email` and `users.username` are both nullable, UNIQUE columns (an +account has whichever identifier its app configures). Migration +`0004-flexible-identity` adds `username` and relaxes `email` to nullable — +in place on PostgreSQL/MySQL, and via a session-preserving table rebuild on +SQLite — so upgrading from 0.3.x keeps every account and live session. + ## Roadmap Honest status — these are **not implemented yet**: -- OAuth / OIDC sign-in (Google, GitHub, generic OIDC) - Multi-factor authentication (TOTP, WebAuthn) +- "Either" identity mode (sign in with email *or* username interchangeably — + the schema already supports it; only the resolver/UI wiring is pending) If you need them today, the building blocks (sessions, `TokenService`, guards) compose underneath whatever you bring; contributions are -welcome. +welcome. (OAuth/OIDC sign-in and JWT access/refresh tokens shipped in 0.3.0.) ## License diff --git a/packages/pyxle-auth/pyproject.toml b/packages/pyxle-auth/pyproject.toml index ae92be8..ccda4c1 100644 --- a/packages/pyxle-auth/pyproject.toml +++ b/packages/pyxle-auth/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "pyxle-auth" -version = "0.3.0" -description = "Authentication plugin for Pyxle: argon2id sessions, password reset and email verification flows, RBAC, scoped API tokens, and request guards." +version = "0.4.0" +description = "Authentication plugin for Pyxle: email- or username-based identity, argon2id sessions, password reset and email verification flows, RBAC, scoped API tokens, and request guards." readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } diff --git a/packages/pyxle-auth/pyxle_auth/_identity.py b/packages/pyxle-auth/pyxle_auth/_identity.py new file mode 100644 index 0000000..0d62256 --- /dev/null +++ b/packages/pyxle-auth/pyxle_auth/_identity.py @@ -0,0 +1,85 @@ +"""Username normalisation and validation. + +Kept dependency-light (only :mod:`pyxle_auth.errors`) and pure — every +function takes its policy as arguments rather than reaching for settings — +so the rules are trivial to unit-test and reuse from both the service and +the plugin's HTTP endpoints. + +Usernames are **lowercase-normalised**: ``"Ada"`` and ``"ada"`` are the same +account. This keeps uniqueness case-insensitive on every backend without a +functional index (which MySQL can't express portably). +""" + +from __future__ import annotations + +import re +from typing import Iterable + +from .errors import AuthError + + +# A conservative default block-list. It guards two things every multi-user +# app cares about: impersonation of system/staff identities, and collisions +# with the routes apps commonly mount (``/login``, ``/settings``, ``/api`` …). +# Apps tune this via ``AuthSettings.username_reserved`` — pass an empty set to +# allow everything, or extend it with your own product's routes. +DEFAULT_RESERVED_USERNAMES: frozenset[str] = frozenset( + { + # system / staff identities + "admin", "administrator", "root", "superuser", "sysadmin", "system", + "support", "help", "helpdesk", "staff", "team", "moderator", "mod", + "owner", "official", "security", "abuse", "postmaster", "webmaster", + "noreply", "no-reply", "donotreply", + # auth / account routes + "login", "logout", "signin", "sign-in", "signout", "sign-out", + "signup", "sign-up", "register", "auth", "oauth", "sso", "session", + "account", "accounts", "settings", "profile", "password", "verify", + "me", "user", "users", + # infra / common app routes + "api", "app", "www", "mail", "email", "ftp", "smtp", "static", + "assets", "public", "cdn", "media", "files", "download", "downloads", + "dashboard", "home", "index", "search", "explore", "billing", + "payment", "payments", "checkout", "status", "health", "metrics", + "about", "contact", "terms", "privacy", "legal", "docs", "blog", + "favicon", "robots", "sitemap", + # placeholders that signal a bug, not a real handle + "null", "undefined", "none", "nan", "anonymous", "guest", "test", + } +) + + +def normalise_username( + raw: str, + *, + min_length: int, + max_length: int, + pattern: str, + reserved: Iterable[str] = DEFAULT_RESERVED_USERNAMES, +) -> str: + """Normalise and validate *raw*, returning the canonical username. + + Trims surrounding whitespace and lowercases, then enforces length, the + allowed-character ``pattern`` (matched against the lowercased value), and + the ``reserved`` block-list. Raises :class:`AuthError` with a + user-facing message on any violation — the same exception type the email + path uses, so callers handle both identifiers uniformly. + """ + username = raw.strip().lower() + if not username: + raise AuthError("Please choose a username.") + if len(username) < min_length: + raise AuthError( + f"Username must be at least {min_length} characters." + ) + if len(username) > max_length: + raise AuthError( + f"Username must be at most {max_length} characters." + ) + if not re.match(pattern, username): + raise AuthError( + "Username may only contain lowercase letters, numbers, " + "hyphens, and underscores." + ) + if username in {r.strip().lower() for r in reserved}: + raise AuthError("That username is reserved. Please choose another.") + return username diff --git a/packages/pyxle-auth/pyxle_auth/errors.py b/packages/pyxle-auth/pyxle_auth/errors.py index 93bbaed..9f77223 100644 --- a/packages/pyxle-auth/pyxle_auth/errors.py +++ b/packages/pyxle-auth/pyxle_auth/errors.py @@ -24,10 +24,24 @@ def __init__(self) -> None: class AccountExists(AuthError): - """Sign-up attempted with an email that already has an account.""" + """Sign-up attempted with an identifier that already has an account. - def __init__(self) -> None: - super().__init__("An account with this email already exists.") + The message names the configured identifier so it reads correctly in both + modes (and doesn't say "email" when a *username* collided — which would + both confuse and, in username mode, leak nothing useful since username + availability is public by design). + """ + + def __init__( + self, message: str | None = None, *, identifier: str = "email" + ) -> None: + if message is None: + message = ( + "That username is already taken." + if identifier == "username" + else "An account with this email already exists." + ) + super().__init__(message) class RateLimited(AuthError): diff --git a/packages/pyxle-auth/pyxle_auth/guards.py b/packages/pyxle-auth/pyxle_auth/guards.py index 2323e60..192f26d 100644 --- a/packages/pyxle-auth/pyxle_auth/guards.py +++ b/packages/pyxle-auth/pyxle_auth/guards.py @@ -7,7 +7,7 @@ @server async def load(request): user = await require_user_page(request) # 401 LoaderError when signed out - return {"email": user.email} + return {"id": user.id, "name": user.username or user.email} @action async def save(request): diff --git a/packages/pyxle-auth/pyxle_auth/middleware.py b/packages/pyxle-auth/pyxle_auth/middleware.py index 441131c..10707e0 100644 --- a/packages/pyxle-auth/pyxle_auth/middleware.py +++ b/packages/pyxle-auth/pyxle_auth/middleware.py @@ -78,6 +78,7 @@ def user_to_json(user: Any) -> dict[str, Any] | None: return { "id": user.id, "email": user.email, + "username": user.username, "emailVerified": user.email_verified_at is not None, "plan": user.plan, "createdAt": user.created_at.isoformat(), @@ -144,6 +145,13 @@ async def _handle(self, request: Request, ctx: Any, service: Any) -> Response | if request.method == "POST": return await self._signup(request, service, settings) return _method_not_allowed("POST") + if ( + settings.identifier == "username" + and path == prefix + "/username-available" + ): + if request.method == "GET": + return await self._username_available(request, service) + return _method_not_allowed("GET") # JWT token endpoints — served only when the JWT service is configured. jwt_service = ctx.get(_JWT_SERVICE) if ctx is not None else None @@ -196,35 +204,68 @@ async def _login(self, request: Request, service: Any, settings: Any) -> Respons body = await _json_body(request) if body is None: return _bad_request("Expected a JSON body.") - email = body.get("email") + field = settings.identifier # "email" (default) or "username" + value = body.get(field) password = body.get("password") - if not isinstance(email, str) or not isinstance(password, str): - return _bad_request("Both 'email' and 'password' are required.") + if not isinstance(value, str) or not isinstance(password, str): + return _bad_request(f"Both '{field}' and 'password' are required.") try: user, cookie = await service.sign_in( - email=email, password=password, ip=_client_ip(request), user_agent=request.headers.get("user-agent"), + **{field: value}, ) except AuthError as exc: return _auth_error_response(exc) return _authenticated_response(user, cookie) + async def _username_available(self, request: Request, service: Any) -> Response: + """``GET {prefix}/username-available?u=`` → ``{"available": bool}``. + + Username availability is intentionally public (a user must know whether + a handle is free before claiming it). A malformed or reserved handle + comes back ``available: false`` with a ``reason`` the picker can show. + """ + name = ( + request.query_params.get("u") + or request.query_params.get("username") + or "" + ) + try: + available = await service.username_available( + name, ip=_client_ip(request) + ) + except RateLimited as exc: + # Too many checks from this IP — 429 with Retry-After (caught before + # the generic AuthError handler, since RateLimited subclasses it). + return _auth_error_response(exc) + except AuthError as exc: + return JSONResponse({"available": False, "reason": str(exc)}) + return JSONResponse({"available": available}) + async def _signup(self, request: Request, service: Any, settings: Any) -> Response: body = await _json_body(request) if body is None: return _bad_request("Expected a JSON body.") - email = body.get("email") + field = settings.identifier # "email" (default) or "username" + value = body.get(field) password = body.get("password") - if not isinstance(email, str) or not isinstance(password, str): - return _bad_request("Both 'email' and 'password' are required.") + if not isinstance(value, str) or not isinstance(password, str): + return _bad_request(f"Both '{field}' and 'password' are required.") + kwargs: dict[str, Any] = {field: value} + # In username mode an app may still collect an optional email (e.g. for + # future password reset); pass it through when supplied. + if settings.identifier == "username": + extra_email = body.get("email") + if isinstance(extra_email, str) and extra_email: + kwargs["email"] = extra_email try: user, cookie = await service.sign_up( - email=email, password=password, ip=_client_ip(request), user_agent=request.headers.get("user-agent"), + **kwargs, ) except AuthError as exc: return _auth_error_response(exc) @@ -241,13 +282,14 @@ async def _token(self, request: Request, service: Any, jwt_service: Any) -> Resp body = await _json_body(request) if body is None: return _bad_request("Expected a JSON body.") - email = body.get("email") + field = service.settings.identifier # "email" (default) or "username" + value = body.get(field) password = body.get("password") - if not isinstance(email, str) or not isinstance(password, str): - return _bad_request("Both 'email' and 'password' are required.") + if not isinstance(value, str) or not isinstance(password, str): + return _bad_request(f"Both '{field}' and 'password' are required.") try: user = await service.verify_credentials( - email=email, password=password, ip=_client_ip(request) + password=password, ip=_client_ip(request), **{field: value} ) except AuthError as exc: return _auth_error_response(exc) @@ -295,7 +337,15 @@ def _auth_seed(user: Any, settings: Any) -> dict[str, Any]: if settings.enable_credentials_api: endpoints["login"] = prefix + "/login" endpoints["signup"] = prefix + "/signup" - return {"user": user_to_json(user), "endpoints": endpoints} + if settings.identifier == "username": + endpoints["usernameAvailable"] = prefix + "/username-available" + # ``identifier`` tells the client which field to render and post — "email" + # (default) or "username". Keeps the form and the server in lockstep. + return { + "user": user_to_json(user), + "identifier": settings.identifier, + "endpoints": endpoints, + } async def _json_body(request: Request) -> dict[str, Any] | None: diff --git a/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.mysql.sql b/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.mysql.sql new file mode 100644 index 0000000..68ecaa0 --- /dev/null +++ b/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.mysql.sql @@ -0,0 +1,12 @@ +-- pyxle-auth flexible identity (0.4.0) — MySQL. +-- +-- Add a nullable, UNIQUE `username` and relax `email` to nullable so an account +-- may be identified by either (see AuthSettings.identifier). In-place ALTERs — +-- no table rebuild, every session preserved. MySQL has no ADD COLUMN IF NOT +-- EXISTS, but the migrator applies each migration exactly once (checksum- +-- tracked), so this runs cleanly on the 0001 schema. MODIFY keeps the column's +-- existing UNIQUE index on `email`; it only drops the NOT NULL. + +ALTER TABLE users ADD COLUMN username VARCHAR(64) NULL UNIQUE; + +ALTER TABLE users MODIFY email VARCHAR(255) NULL; diff --git a/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.postgresql.sql b/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.postgresql.sql new file mode 100644 index 0000000..b6ff394 --- /dev/null +++ b/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.postgresql.sql @@ -0,0 +1,13 @@ +-- pyxle-auth flexible identity (0.4.0) — PostgreSQL. +-- +-- Add a nullable, UNIQUE `username` and relax `email` to nullable so an account +-- may be identified by either (see AuthSettings.identifier). All in-place +-- ALTERs — no table rebuild, every session preserved. Idempotent: re-running is +-- a no-op (the migrator applies each migration once, but IF NOT EXISTS / DROP +-- NOT NULL keep it safe regardless). + +ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(64); + +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS users_username_key ON users (username); diff --git a/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.sql b/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.sql new file mode 100644 index 0000000..4b9401b --- /dev/null +++ b/packages/pyxle-auth/pyxle_auth/migrations/0004-flexible-identity.sql @@ -0,0 +1,49 @@ +-- pyxle-auth flexible identity (0.4.0) — SQLite. +-- +-- Adds a nullable, UNIQUE `username` and relaxes `email` to nullable, so an +-- account may be identified by either (see AuthSettings.identifier). SQLite +-- cannot drop a NOT NULL via ALTER, and the migrator runs this inside a +-- transaction with foreign_keys ON (so the pragma can't be toggled) — so we +-- rebuild `users`. `sessions` is a leaf (nothing references it), so we stash +-- its rows, drop it, rebuild `users` cleanly, then recreate `sessions` with +-- its original FK + indexes and restore every row. No login is lost. + +CREATE TABLE sessions_backup_0004 AS SELECT * FROM sessions; + +DROP TABLE sessions; + +CREATE TABLE users_v2 ( + id VARCHAR(64) PRIMARY KEY, + email VARCHAR(255) UNIQUE, + username VARCHAR(64) UNIQUE, + password_hash TEXT NOT NULL, + email_verified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + plan TEXT NOT NULL +); + +INSERT INTO users_v2 (id, email, password_hash, email_verified_at, created_at, plan) + SELECT id, email, password_hash, email_verified_at, created_at, plan FROM users; + +DROP TABLE users; + +ALTER TABLE users_v2 RENAME TO users; + +CREATE TABLE sessions ( + token_sha256 VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + user_agent TEXT, + ip TEXT +); + +INSERT INTO sessions (token_sha256, user_id, created_at, expires_at, user_agent, ip) + SELECT token_sha256, user_id, created_at, expires_at, user_agent, ip + FROM sessions_backup_0004; + +DROP TABLE sessions_backup_0004; + +CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id); + +CREATE INDEX IF NOT EXISTS sessions_expires ON sessions (expires_at); diff --git a/packages/pyxle-auth/pyxle_auth/models.py b/packages/pyxle-auth/pyxle_auth/models.py index c0e6865..e423dd9 100644 --- a/packages/pyxle-auth/pyxle_auth/models.py +++ b/packages/pyxle-auth/pyxle_auth/models.py @@ -22,7 +22,11 @@ class User: """ id: str - email: str + # Exactly one of ``email`` / ``username`` is the login identifier (see + # ``AuthSettings.identifier``); the other may be ``None``. Email-mode apps + # always have an email and a ``None`` username — unchanged from before. + email: str | None + username: str | None email_verified_at: datetime | None created_at: datetime plan: str diff --git a/packages/pyxle-auth/pyxle_auth/plugin.py b/packages/pyxle-auth/pyxle_auth/plugin.py index c82587f..05bd908 100644 --- a/packages/pyxle-auth/pyxle_auth/plugin.py +++ b/packages/pyxle-auth/pyxle_auth/plugin.py @@ -122,8 +122,13 @@ "emailVerifyTtlSeconds": "email_verify_ttl_seconds", "rateLimitSignInPerHour": "rate_limit_sign_in_per_hour", "rateLimitSignUpPerHour": "rate_limit_sign_up_per_hour", + "rateLimitUsernameCheckPerHour": "rate_limit_username_check_per_hour", "rateLimitPasswordResetPerHour": "rate_limit_password_reset_per_hour", "requireEmailVerified": "require_email_verified", + "identifier": "identifier", + "usernameMinLength": "username_min_length", + "usernameMaxLength": "username_max_length", + "usernamePattern": "username_pattern", "strict": "strict", } diff --git a/packages/pyxle-auth/pyxle_auth/service.py b/packages/pyxle-auth/pyxle_auth/service.py index a0979f6..184f308 100644 --- a/packages/pyxle-auth/pyxle_auth/service.py +++ b/packages/pyxle-auth/pyxle_auth/service.py @@ -53,6 +53,7 @@ from pyxle_db import DatabaseLike, IntegrityError from pyxle_auth._ddl import ensure_index, timestamp_type +from pyxle_auth._identity import normalise_username from pyxle_auth.errors import ( AccountExists, AuthError, @@ -174,7 +175,8 @@ async def ensure_schema(self) -> None: """ CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) PRIMARY KEY, - email VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) UNIQUE, + username VARCHAR(64) UNIQUE, password_hash TEXT NOT NULL, email_verified_at {ts}, created_at {ts} NOT NULL, @@ -203,22 +205,59 @@ async def ensure_schema(self) -> None: # ---- sign-up --------------------------------------------------------------- + def _normalise_username(self, raw: str) -> str: + """Validate + normalise a username against this service's policy.""" + return normalise_username( + raw, + min_length=self._settings.username_min_length, + max_length=self._settings.username_max_length, + pattern=self._settings.username_pattern, + reserved=self._settings.username_reserved, + ) + + def _resolve_signup_identifiers( + self, *, email: str | None, username: str | None + ) -> tuple[str | None, str | None]: + """Validate sign-up identifiers, returning ``(email, username)`` normalised. + + The configured primary identifier (:attr:`AuthSettings.identifier`) is + required; the other is accepted and stored when supplied — so a + username-mode app may still collect an optional email. Raises + :class:`AuthError` if the primary is missing or any value is invalid. + """ + email_n = _normalise_email(email) if email else None + username_n = self._normalise_username(username) if username else None + if self._settings.identifier == "username": + if username_n is None: + raise AuthError("Please choose a username.") + elif email_n is None: + raise AuthError("Please enter an email address.") + return email_n, username_n + async def sign_up( self, *, - email: str, password: str, + email: str | None = None, + username: str | None = None, ip: str | None = None, user_agent: str | None = None, ) -> tuple[User, SessionCookie]: """Create an account and return the user + a session cookie. + Pass the identifier your app is configured for: an ``email`` (default), + a ``username`` (when ``AuthSettings.identifier == "username"``), or both + (the configured one is required, the other optional). + Raises: - :class:`AccountExists` if ``email`` already has a user. + :class:`AccountExists` if the email or username is already taken. :class:`WeakPassword` if the password violates policy. :class:`RateLimited` if the caller (by IP) exceeded the hourly cap. + :class:`AuthError` if the required identifier is missing/invalid. """ - email_n = _normalise_email(email) + email_n, username_n = self._resolve_signup_identifiers( + email=email, username=username + ) self._check_password_policy(password) if ip is not None: @@ -236,14 +275,16 @@ async def sign_up( try: await self._db.execute( """ - INSERT INTO users (id, email, password_hash, created_at, plan) - VALUES (?, ?, ?, ?, ?) + INSERT INTO users (id, email, username, password_hash, created_at, plan) + VALUES (?, ?, ?, ?, ?, ?) """, - (user_id, email_n, password_hash, _now_utc(), _DEFAULT_PLAN), + (user_id, email_n, username_n, password_hash, _now_utc(), _DEFAULT_PLAN), ) except IntegrityError: - # UNIQUE(email) - raise AccountExists() from None + # UNIQUE(email) or UNIQUE(username) — identifier already taken. The + # message names the configured identifier so it's correct in both + # modes (never says "email" for a username collision). + raise AccountExists(identifier=self._settings.identifier) from None user = await self._load_user_by_id(user_id) assert user is not None @@ -254,22 +295,55 @@ async def sign_up( ) return user, session_cookie + async def username_available( + self, username: str, *, ip: str | None = None + ) -> bool: + """Return ``True`` if *username* is valid **and** not yet taken. + + Validates against the configured policy first — raising + :class:`AuthError` (with the reason) if the handle is malformed or + reserved — then checks the table. Availability is intentionally public: + a user must know whether a handle is free before claiming it. + + Pass ``ip`` (the public HTTP endpoint does) to rate-limit checks + per-IP — enough for a debounced picker, but enough friction to make + bulk enumeration of the user list impractical. Raises + :class:`RateLimited` when the per-IP cap is exceeded. + """ + if ip is not None: + rl = await self._ratelimiter.check_and_increment( + scope="auth:username-check", + identifier=ip, + limit=self._settings.rate_limit_username_check_per_hour, + ) + if not rl.allowed: + raise RateLimited(rl.retry_after_seconds) + username_n = self._normalise_username(username) + row = await self._db.fetchone( + "SELECT 1 FROM users WHERE username = ?", (username_n,) + ) + return row is None + # ---- sign-in --------------------------------------------------------------- async def sign_in( self, *, - email: str, password: str, + email: str | None = None, + username: str | None = None, ip: str | None = None, user_agent: str | None = None, ) -> tuple[User, SessionCookie]: """Verify credentials and return a fresh session. - Rate-limited by both ``ip`` and ``email`` independently; either - can trip the limiter. + Pass the configured login identifier — ``email`` (default) or + ``username``. Rate-limited by both ``ip`` and the identifier + independently; either can trip the limiter. """ - user = await self.verify_credentials(email=email, password=password, ip=ip) + user = await self.verify_credentials( + email=email, username=username, password=password, ip=ip + ) session_cookie = await self._issue_session( user_id=user.id, ip=ip, @@ -277,14 +351,35 @@ async def sign_in( ) return user, session_cookie + def _login_lookup( + self, *, email: str | None, username: str | None + ) -> tuple[str, str]: + """Resolve the ``(column, value)`` to match at sign-in. + + Uses the configured identifier. Unlike sign-up this does NOT run the + full username policy — a malformed handle simply matches no row and + surfaces as :class:`InvalidCredentials`, so sign-in never leaks the + username rules and stays enumeration-safe. Email keeps its existing + light normalisation. + """ + if self._settings.identifier == "username": + value = (username or "").strip().lower() + if not value: + raise InvalidCredentials() + return "username", value + if not email: + raise InvalidCredentials() + return "email", _normalise_email(email) + async def verify_credentials( self, *, - email: str, password: str, + email: str | None = None, + username: str | None = None, ip: str | None = None, ) -> User: - """Verify an email + password and return the :class:`User`. + """Verify the configured identifier + password, returning the :class:`User`. The rate-limited, constant-time, enumeration-safe core that :meth:`sign_in` builds on — split out so callers that authenticate @@ -293,7 +388,7 @@ async def verify_credentials( Raises :class:`InvalidCredentials`, :class:`RateLimited`, or :class:`EmailNotVerified` exactly as :meth:`sign_in` does. """ - email_n = _normalise_email(email) + column, ident_n = self._login_lookup(email=email, username=username) # Reject over-length passwords BEFORE any argon2 work. A password # longer than the policy max can never match a stored hash (sign_up @@ -304,9 +399,9 @@ async def verify_credentials( raise InvalidCredentials() # The IP bucket throttles a single abusive source pre-verify. The - # email bucket is checked too, but a CORRECT password always wins - # (below), so flooding a victim's email with wrong guesses can never - # lock the legitimate owner out of their own account. + # per-identifier bucket is checked too, but a CORRECT password always + # wins (below), so flooding a victim's account with wrong guesses can + # never lock the legitimate owner out of their own account. if ip is not None: ip_rl = await self._ratelimiter.check_and_increment( scope="auth:sign-in:ip", @@ -315,16 +410,18 @@ async def verify_credentials( ) if not ip_rl.allowed: raise RateLimited(ip_rl.retry_after_seconds) - email_rl = await self._ratelimiter.check_and_increment( - scope="auth:sign-in:email", - identifier=email_n, + ident_rl = await self._ratelimiter.check_and_increment( + scope=f"auth:sign-in:{column}", + identifier=ident_n, limit=self._settings.rate_limit_sign_in_per_hour, ) - email_limited = not email_rl.allowed + ident_limited = not ident_rl.allowed + # ``column`` is one of our own literals ("email"/"username"), never + # user input, so the interpolation here is injection-safe. row = await self._db.fetchone( - "SELECT id, password_hash, email_verified_at FROM users WHERE email = ?", - (email_n,), + f"SELECT id, email, password_hash, email_verified_at FROM users WHERE {column} = ?", + (ident_n,), ) if row is None: # Constant-time dummy verify so we don't leak account existence @@ -333,22 +430,22 @@ async def verify_credentials( self._hasher.verify(self._timing_hash, password) except VerifyMismatchError: pass - # No real account to protect; an exhausted email bucket may + # No real account to protect; an exhausted identifier bucket may # surface as RateLimited, otherwise it's a normal bad login. - if email_limited: - raise RateLimited(email_rl.retry_after_seconds) + if ident_limited: + raise RateLimited(ident_rl.retry_after_seconds) raise InvalidCredentials() try: self._hasher.verify(row["password_hash"], password) except (VerifyMismatchError, InvalidHashError): - # Wrong password: NOW the email bucket may block, throttling a + # Wrong password: NOW the identifier bucket may block, throttling a # distributed guessing campaign against this one account. - if email_limited: - raise RateLimited(email_rl.retry_after_seconds) from None + if ident_limited: + raise RateLimited(ident_rl.retry_after_seconds) from None raise InvalidCredentials() from None - # Correct password from here on — the email bucket is deliberately + # Correct password from here on — the identifier bucket is deliberately # NOT consulted, so the owner is never locked out by others' failures. # Opportunistic rehash: if our argon2 params have moved on since @@ -361,17 +458,23 @@ async def verify_credentials( (new_hash, row["id"]), ) + # Email verification only gates accounts that HAVE an email. A + # username-only account (email NULL) can never verify an email it + # doesn't have, so requiring verification must not lock it out. if ( self._settings.require_email_verified + and row["email"] is not None and row["email_verified_at"] is None ): raise EmailNotVerified("Please verify your email before signing in.") - # Legitimate sign-in clears the IP+email buckets so this user + # Legitimate sign-in clears the IP + identifier buckets so this user # isn't locked out next hour. if ip is not None: await self._ratelimiter.reset(scope="auth:sign-in:ip", identifier=ip) - await self._ratelimiter.reset(scope="auth:sign-in:email", identifier=email_n) + await self._ratelimiter.reset( + scope=f"auth:sign-in:{column}", identifier=ident_n + ) user = await self._load_user_by_id(row["id"]) assert user is not None @@ -792,7 +895,7 @@ def _check_password_policy(self, password: str) -> None: async def _load_user_by_id(self, user_id: str) -> User | None: row = await self._db.fetchone( """ - SELECT id, email, email_verified_at, created_at, plan + SELECT id, email, username, email_verified_at, created_at, plan FROM users WHERE id = ? """, (user_id,), @@ -802,6 +905,7 @@ async def _load_user_by_id(self, user_id: str) -> User | None: return User( id=row["id"], email=row["email"], + username=row["username"], email_verified_at=_aware_or_none(row["email_verified_at"]), created_at=_aware(row["created_at"]), plan=row["plan"], diff --git a/packages/pyxle-auth/pyxle_auth/settings.py b/packages/pyxle-auth/pyxle_auth/settings.py index e9b684c..2389be7 100644 --- a/packages/pyxle-auth/pyxle_auth/settings.py +++ b/packages/pyxle-auth/pyxle_auth/settings.py @@ -9,9 +9,12 @@ from __future__ import annotations import os +import re from dataclasses import dataclass, replace from typing import Any, Mapping +from ._identity import DEFAULT_RESERVED_USERNAMES + # --------------------------------------------------------------------------- # Cookie defaults @@ -102,10 +105,35 @@ class AuthSettings: rate_limit_sign_in_per_hour: int = 10 rate_limit_sign_up_per_hour: int = 5 rate_limit_password_reset_per_hour: int = 3 + # Per-IP cap on the public username-availability endpoint. Generous enough + # for a debounced "as you type" picker, tight enough to make bulk + # enumeration of the user list impractical. + rate_limit_username_check_per_hour: int = 120 # Email verification require_email_verified: bool = False + # ---- Identity model ---------------------------------------------------- + # Which credential identifies a user at sign-in. + # "email" (default) — the historical behaviour; every existing app is + # unaffected. Sign-up/sign-in take an email; verification and + # password-reset flows are available. + # "username" — a unique, lowercase username is the credential. No email + # is required (apps may still collect one optionally, e.g. for + # future reset); email verification / reset simply go unused. + # The schema carries both columns (each UNIQUE, nullable) so this is a pure + # config switch, and leaves room for a future "either" (login with either) + # mode without another migration. + identifier: str = "email" + + # Username policy — consulted whenever a username is validated (sign-up in + # username mode, or any app that collects a username). Usernames are + # lowercase-normalised, so uniqueness is case-insensitive on every backend. + username_min_length: int = 3 + username_max_length: int = 30 + username_pattern: str = r"^[a-z0-9_-]+$" + username_reserved: frozenset[str] = DEFAULT_RESERVED_USERNAMES + # Whether we're in a strict production posture. Currently only # enforces ``cookie_secure=True`` at construction time. strict: bool = True @@ -145,6 +173,28 @@ def __post_init__(self) -> None: raise ValueError("rate_limit_password_reset_per_hour must be positive") if self.password_max_length <= self.password_min_length: raise ValueError("password_max_length must exceed password_min_length") + if self.identifier not in ("email", "username"): + raise ValueError( + f'identifier must be "email" or "username", got {self.identifier!r}' + ) + if self.username_min_length < 1: + raise ValueError("username_min_length must be >= 1") + if self.username_max_length < self.username_min_length: + raise ValueError( + "username_max_length must be >= username_min_length" + ) + # Fail loud at boot on an invalid username_pattern instead of crashing + # on the first sign-up (or the public availability endpoint) with a + # raw re.error. (A catastrophic-backtracking pattern is a config-author + # footgun, not an attacker vector — the pattern is never user input.) + try: + re.compile(self.username_pattern) + except re.error as exc: + raise ValueError( + f"username_pattern is not a valid regular expression: {exc}" + ) from exc + if self.rate_limit_username_check_per_hour <= 0: + raise ValueError("rate_limit_username_check_per_hour must be positive") # Argon2 strength floors (OWASP argon2id guidance). Enforced only in # strict mode so for_tests() can use fast/weak parameters; a real # deployment with a fat-fingered PYXLE_AUTH_ARGON_* env fails loudly @@ -223,10 +273,19 @@ def _bool(key: str, default: bool) -> bool: rate_limit_sign_up_per_hour=_int( "PYXLE_AUTH_RL_SIGN_UP_PER_HOUR", 5 ), + rate_limit_username_check_per_hour=_int( + "PYXLE_AUTH_RL_USERNAME_CHECK_PER_HOUR", 120 + ), rate_limit_password_reset_per_hour=_int( "PYXLE_AUTH_RATE_LIMIT_PASSWORD_RESET_PER_HOUR", 3 ), require_email_verified=_bool("PYXLE_AUTH_REQUIRE_VERIFIED", False), + identifier=( + os.environ.get("PYXLE_AUTH_IDENTIFIER", "email").strip().lower() + or "email" + ), + username_min_length=_int("PYXLE_AUTH_USERNAME_MIN", 3), + username_max_length=_int("PYXLE_AUTH_USERNAME_MAX", 30), strict=strict, ) if overrides: diff --git a/packages/pyxle-auth/tests/test_database_contract.py b/packages/pyxle-auth/tests/test_database_contract.py index 72e3252..a7fdfad 100644 --- a/packages/pyxle-auth/tests/test_database_contract.py +++ b/packages/pyxle-auth/tests/test_database_contract.py @@ -182,4 +182,5 @@ async def test_plugin_startup_accepts_any_db_database_service( "0001-pyxle-auth-core", "0002-oauth-identities", "0003-jwt-refresh-tokens", + "0004-flexible-identity", ] \ No newline at end of file diff --git a/packages/pyxle-auth/tests/test_flexible_identity.py b/packages/pyxle-auth/tests/test_flexible_identity.py new file mode 100644 index 0000000..307d3eb --- /dev/null +++ b/packages/pyxle-auth/tests/test_flexible_identity.py @@ -0,0 +1,234 @@ +"""Tests for the flexible-identity feature (0.4.0): username-mode auth. + +Covers the three new layers — the pure ``normalise_username`` policy, the +``AuthSettings.identifier`` switch + username knobs, and the ``AuthService`` +running in username mode — plus a guard that email mode is untouched. The +migration itself is exercised on real engines in ``test_live_backends.py``. +""" + +from __future__ import annotations + +import pytest + +from pyxle_auth import AuthService, AuthSettings +from pyxle_auth._identity import DEFAULT_RESERVED_USERNAMES, normalise_username +from pyxle_auth.errors import AccountExists, AuthError, InvalidCredentials +from pyxle_db import Database + +_POLICY = dict(min_length=3, max_length=30, pattern=r"^[a-z0-9_-]+$") + + +# --------------------------------------------------------------------------- +# normalise_username — the pure policy +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "raw, expected", + [ + (" Ada_Lovelace ", "ada_lovelace"), # trims + lowercases + ("dev-99", "dev-99"), + ("UPPER", "upper"), + ], +) +def test_normalise_username_canonicalises(raw: str, expected: str) -> None: + assert normalise_username(raw, **_POLICY) == expected + + +@pytest.mark.parametrize( + "bad", + ["ab", "x" * 31, "has space", "bang!", "Ünïcode", "", " "], +) +def test_normalise_username_rejects_invalid(bad: str) -> None: + with pytest.raises(AuthError): + normalise_username(bad, **_POLICY) + + +@pytest.mark.parametrize("name", ["admin", "ADMIN", "Root", "api", "login"]) +def test_normalise_username_rejects_reserved(name: str) -> None: + with pytest.raises(AuthError, match="reserved"): + normalise_username(name, **_POLICY) + + +def test_reserved_set_is_substantial_and_lowercase() -> None: + assert len(DEFAULT_RESERVED_USERNAMES) >= 50 + assert all(n == n.lower() for n in DEFAULT_RESERVED_USERNAMES) + + +def test_reserved_block_list_is_configurable() -> None: + # An app can clear the block-list entirely… + assert normalise_username("admin", **_POLICY, reserved=frozenset()) == "admin" + # …or supply its own. + with pytest.raises(AuthError, match="reserved"): + normalise_username("ceo", **_POLICY, reserved={"ceo"}) + + +# --------------------------------------------------------------------------- +# AuthSettings.identifier +# --------------------------------------------------------------------------- + + +def test_default_identifier_is_email_for_backward_compat() -> None: + assert AuthSettings(strict=False).identifier == "email" + + +def test_identifier_must_be_email_or_username() -> None: + with pytest.raises(ValueError, match="identifier"): + AuthSettings(strict=False, identifier="phone") + + +def test_username_length_bounds_validated() -> None: + with pytest.raises(ValueError): + AuthSettings(strict=False, username_min_length=0) + with pytest.raises(ValueError): + AuthSettings(strict=False, username_min_length=5, username_max_length=4) + + +def test_identifier_reads_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYXLE_AUTH_IDENTIFIER", "username") + assert AuthSettings.from_env(strict=False).identifier == "username" + + +# --------------------------------------------------------------------------- +# AuthService in username mode +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def uauth(db: Database) -> AuthService: + svc = AuthService( + db, AuthSettings(strict=False, identifier="username").for_tests() + ) + await svc.ensure_schema() + return svc + + +async def test_username_signup_stores_lowercased_no_email(uauth: AuthService) -> None: + user, cookie = await uauth.sign_up(username="Ada_Lovelace", password="correct horse staple") + assert user.username == "ada_lovelace" + assert user.email is None + assert cookie.value + + +async def test_username_signin_is_case_insensitive(uauth: AuthService) -> None: + created, _ = await uauth.sign_up(username="ada", password="correct horse staple") + user = await uauth.verify_credentials(username="ADA", password="correct horse staple") + assert user.id == created.id + + +async def test_username_wrong_password_is_invalid_credentials(uauth: AuthService) -> None: + await uauth.sign_up(username="ada", password="correct horse staple") + with pytest.raises(InvalidCredentials): + await uauth.verify_credentials(username="ada", password="wrong guess here") + + +async def test_unknown_username_is_invalid_credentials(uauth: AuthService) -> None: + with pytest.raises(InvalidCredentials): + await uauth.verify_credentials(username="nobody", password="whatever pass") + + +async def test_duplicate_username_rejected_case_insensitively(uauth: AuthService) -> None: + await uauth.sign_up(username="ada", password="correct horse staple") + with pytest.raises(AccountExists): + await uauth.sign_up(username="ADA", password="another good password") + + +async def test_signup_rejects_reserved_and_malformed(uauth: AuthService) -> None: + with pytest.raises(AuthError): + await uauth.sign_up(username="admin", password="correct horse staple") + with pytest.raises(AuthError): + await uauth.sign_up(username="no spaces", password="correct horse staple") + + +async def test_signup_requires_username_in_username_mode(uauth: AuthService) -> None: + with pytest.raises(AuthError): + await uauth.sign_up(email="someone@example.com", password="correct horse staple") + + +async def test_username_available(uauth: AuthService) -> None: + assert await uauth.username_available("free_handle") is True + await uauth.sign_up(username="taken", password="correct horse staple") + assert await uauth.username_available("TAKEN") is False # case-insensitive + with pytest.raises(AuthError): + await uauth.username_available("admin") # reserved isn't "available" + + +async def test_username_mode_accepts_optional_email(uauth: AuthService) -> None: + # Apps may still collect an email in username mode (e.g. for future reset). + user, _ = await uauth.sign_up( + username="ada", email="Ada@Example.com", password="correct horse staple" + ) + assert user.username == "ada" and user.email == "ada@example.com" + + +# --------------------------------------------------------------------------- +# Email mode is untouched +# --------------------------------------------------------------------------- + + +async def test_email_mode_still_works(auth: AuthService) -> None: + user, _ = await auth.sign_up(email="grace@example.com", password="correct horse staple") + assert user.email == "grace@example.com" and user.username is None + back = await auth.verify_credentials(email="grace@example.com", password="correct horse staple") + assert back.id == user.id + + +# --------------------------------------------------------------------------- +# Security-review fixes (0.4.0) +# --------------------------------------------------------------------------- + + +async def test_duplicate_error_message_names_username_not_email(uauth: AuthService) -> None: + # In username mode the "already exists" error must say username — never + # leak/confuse with "email" (which the user never supplied). + await uauth.sign_up(username="ada", password="correct horse staple") + with pytest.raises(AccountExists) as exc: + await uauth.sign_up(username="ada", password="another good password") + assert "username" in str(exc.value).lower() + assert "email" not in str(exc.value).lower() + + +async def test_duplicate_error_message_says_email_in_email_mode(auth: AuthService) -> None: + await auth.sign_up(email="grace@example.com", password="correct horse staple") + with pytest.raises(AccountExists) as exc: + await auth.sign_up(email="grace@example.com", password="another good password") + assert "email" in str(exc.value).lower() + + +async def test_require_email_verified_does_not_lock_out_usernameonly(db: Database) -> None: + # A username-only account has no email to verify — requiring verification + # must not make it permanently unable to sign in. + svc = AuthService( + db, + AuthSettings( + strict=False, identifier="username", require_email_verified=True + ).for_tests(), + ) + await svc.ensure_schema() + user, _ = await svc.sign_up(username="ada", password="correct horse staple") + signed_in = await svc.verify_credentials(username="ada", password="correct horse staple") + assert signed_in.id == user.id # not blocked + + +async def test_username_available_rate_limited_per_ip(db: Database) -> None: + from pyxle_auth.errors import RateLimited + + svc = AuthService( + db, + AuthSettings( + strict=False, identifier="username", rate_limit_username_check_per_hour=2 + ).for_tests(), + ) + await svc.ensure_schema() + assert await svc.username_available("one", ip="9.9.9.9") is True + assert await svc.username_available("two", ip="9.9.9.9") is True + with pytest.raises(RateLimited): + await svc.username_available("three", ip="9.9.9.9") + # A different IP is unaffected; no-IP internal calls are never throttled. + assert await svc.username_available("four", ip="8.8.8.8") is True + assert await svc.username_available("five") is True + + +def test_invalid_username_pattern_rejected_at_construction() -> None: + with pytest.raises(ValueError, match="username_pattern"): + AuthSettings(strict=False, username_pattern="(unclosed") diff --git a/packages/pyxle-auth/tests/test_guards.py b/packages/pyxle-auth/tests/test_guards.py index 79bd50d..0d02f92 100644 --- a/packages/pyxle-auth/tests/test_guards.py +++ b/packages/pyxle-auth/tests/test_guards.py @@ -41,6 +41,7 @@ def _make_user(user_id: str = "u1") -> User: return User( id=user_id, email="alice@example.com", + username=None, email_verified_at=None, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), plan="free", diff --git a/packages/pyxle-auth/tests/test_live_backends.py b/packages/pyxle-auth/tests/test_live_backends.py index e30d98c..6a4708a 100644 --- a/packages/pyxle-auth/tests/test_live_backends.py +++ b/packages/pyxle-auth/tests/test_live_backends.py @@ -114,9 +114,43 @@ async def test_schema_applies_and_is_idempotent(live_db: Database, services) -> "0001-pyxle-auth-core", "0002-oauth-identities", "0003-jwt-refresh-tokens", + "0004-flexible-identity", ] +async def test_username_mode_lifecycle(live_db: Database) -> None: # type: ignore[no-untyped-def] + """The 0004 migration + username auth on a real engine: apply the schema, + then sign up and sign in by username (no email) end-to-end. + + Exercises the per-dialect ``0004`` migration (nullable email + UNIQUE + username) on the live PostgreSQL/MySQL servers — the same path the SQLite + suite covers — and proves username mode round-trips through the real DB. + """ + settings = AuthSettings(strict=False, identifier="username").for_tests() + auth = AuthService(live_db, settings) + rbac = RoleService(live_db) + tokens = TokenService(live_db) + api_tokens = ApiTokenService(live_db) + await _apply_schema(live_db, (auth, rbac, tokens, api_tokens)) + + import uuid + + handle = f"user_{uuid.uuid4().hex[:12]}" + user, cookie = await auth.sign_up(username=handle, password="correct horse staple") + assert user.username == handle and user.email is None + assert (await auth.resolve_session(cookie_value=cookie.value)).id == user.id + + # Case-insensitive sign-in by username; no email anywhere. + _, fresh = await auth.sign_in(username=handle.upper(), password="correct horse staple") + assert fresh.value + with pytest.raises(InvalidCredentials): + await auth.sign_in(username=handle, password="wrong password entirely") + + # Username uniqueness is enforced by the live DB's UNIQUE(username). + assert await auth.username_available(handle) is False + assert await auth.username_available(f"free_{uuid.uuid4().hex[:8]}") is True + + async def test_full_account_lifecycle(live_db: Database, services) -> None: # type: ignore[no-untyped-def] """One pass through every table: account, session, rate-limit rows, single-use tokens, RBAC, API tokens, password reset.""" diff --git a/packages/pyxle-auth/tests/test_middleware.py b/packages/pyxle-auth/tests/test_middleware.py index 8717044..23672be 100644 --- a/packages/pyxle-auth/tests/test_middleware.py +++ b/packages/pyxle-auth/tests/test_middleware.py @@ -27,7 +27,7 @@ from pyxle.plugins import PluginContext -from pyxle_auth import AuthService +from pyxle_auth import AuthService, AuthSettings from pyxle_auth.middleware import AuthSessionMiddleware, user_to_json Handler = Callable[[Request], Awaitable[Response]] @@ -831,6 +831,7 @@ def test_user_to_json_marks_verified_email() -> None: verified = User( id="u1", email="bob@example.com", + username=None, email_verified_at=datetime(2026, 1, 1, tzinfo=timezone.utc), created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), plan="pro", @@ -839,7 +840,112 @@ def test_user_to_json_marks_verified_email() -> None: assert out == { "id": "u1", "email": "bob@example.com", + "username": None, "emailVerified": True, "plan": "pro", "createdAt": "2026-01-01T00:00:00+00:00", } + + +# --------------------------------------------------------------------------- +# Username-mode endpoints (flexible identity, 0.4.0) +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def username_auth(db: Any) -> AuthService: + svc = AuthService( + db, AuthSettings(strict=False, identifier="username").for_tests() + ) + await svc.ensure_schema() + return svc + + +async def _reject_call_next(request: Request) -> Response: # pragma: no cover + raise AssertionError("auth endpoint must terminate in the middleware") + + +async def test_signup_via_username_endpoint( + username_auth: AuthService, middleware: _DriveMiddleware +) -> None: + ctx = _ctx(username_auth) + resp = await middleware.dispatch( + _request_json( + ctx, + path="/auth/signup", + body={"username": "Ada", "password": "correct horse battery"}, + ), + _reject_call_next, + ) + assert resp.status_code == 201 + payload = _body(resp) + assert payload["user"]["username"] == "ada" + assert payload["user"]["email"] is None + assert "pyxle_session=" in resp.headers["set-cookie"] + + +async def test_signup_username_endpoint_requires_username_field( + username_auth: AuthService, middleware: _DriveMiddleware +) -> None: + ctx = _ctx(username_auth) + resp = await middleware.dispatch( + _request_json( + ctx, + path="/auth/signup", + body={"email": "x@y.com", "password": "correct horse battery"}, + ), + _reject_call_next, + ) + assert resp.status_code == 400 # the configured identifier ('username') is missing + + +async def test_login_via_username_endpoint_is_case_insensitive( + username_auth: AuthService, middleware: _DriveMiddleware +) -> None: + await username_auth.sign_up(username="ada", password="correct horse battery") + ctx = _ctx(username_auth) + resp = await middleware.dispatch( + _request_json( + ctx, + path="/auth/login", + body={"username": "ADA", "password": "correct horse battery"}, + ), + _reject_call_next, + ) + assert resp.status_code == 200 + assert _body(resp)["user"]["username"] == "ada" + + +async def test_username_available_endpoint( + username_auth: AuthService, middleware: _DriveMiddleware +) -> None: + await username_auth.sign_up(username="taken", password="correct horse battery") + ctx = _ctx(username_auth) + app = SimpleNamespace(state=SimpleNamespace(pyxle_plugins=ctx)) + + async def receive() -> dict: + return {"type": "http.request", "body": b"", "more_body": False} + + def get(query: bytes) -> Request: + scope = { + "type": "http", + "method": "GET", + "path": "/auth/username-available", + "headers": [], + "query_string": query, + "client": ("203.0.113.9", 5555), + "app": app, + "state": {}, + } + return Request(scope, receive) + + r_free = await middleware.dispatch(get(b"u=freehandle"), _reject_call_next) + assert r_free.status_code == 200 + assert _body(r_free)["available"] is True + + r_taken = await middleware.dispatch(get(b"u=taken"), _reject_call_next) + assert _body(r_taken)["available"] is False + + r_reserved = await middleware.dispatch(get(b"u=admin"), _reject_call_next) + body = _body(r_reserved) + assert body["available"] is False and "reserved" in body.get("reason", "").lower()