Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/pyxle-auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 68 additions & 6 deletions packages/pyxle-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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=<name>`
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,
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/pyxle-auth/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
85 changes: 85 additions & 0 deletions packages/pyxle-auth/pyxle_auth/_identity.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 17 additions & 3 deletions packages/pyxle-auth/pyxle_auth/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion packages/pyxle-auth/pyxle_auth/guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading