Metadata-Version: 2.5
Name: ybz-dj-auth
Version: 0.5.0
Summary: Reusable Django app providing production-ready authentication: email login, passkeys, TOTP, social auth.
Project-URL: Repository, https://github.com/damycra/dj-auth
License: MIT
Requires-Python: >=3.13
Requires-Dist: django-allauth[mfa]>=65.0
Requires-Dist: django>=5.2
Provides-Extra: dev
Requires-Dist: django-allauth[socialaccount]>=65.0; extra == 'dev'
Requires-Dist: pytest-django>=4.9; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: python-decouple>=3.8; extra == 'dev'
Requires-Dist: ruff>=0.9; extra == 'dev'
Requires-Dist: tox>=4.23; extra == 'dev'
Requires-Dist: whitenoise>=6.9; extra == 'dev'
Description-Content-Type: text/markdown

# dj-auth

A reusable Django app providing production-ready authentication out of the box:

- **Email login** — email/password auth via django-allauth, no username required
- **Passkeys** — WebAuthn/FIDO2 passkey login (biometrics, hardware keys)
- **TOTP / 2FA** — authenticator app support with recovery codes
- **Social auth** — branded Google, Apple, Microsoft and GitHub buttons, others easy to add
- **Polished UI** — Tailwind CSS templates for login, signup, MFA settings, and more
- **Zero models** — no migrations; drop it into any project without touching your schema
- **Typed** — ships a `py.typed` marker, so your mypy sees real signatures rather than `Any`
- **Internal API** — optional node-to-node user lookup for multi-node projects, with a route
  restriction that keeps it off the public internet
- **System checks** — warns you at startup if required dependencies are misconfigured

## Quick Start

### 1. Install

```bash
# From PyPI (published as "ybz-dj-auth"; the importable package is still dj_auth)
pip install ybz-dj-auth

# Or install directly from GitHub:

# Latest commit on main
pip install git+https://github.com/damycra/dj-auth.git

# Specific tagged release (recommended for reproducible builds)
pip install git+https://github.com/damycra/dj-auth.git@v0.1.0

# Or download the wheel from a GitHub Release and install locally
pip install ybz_dj_auth-0.2.1-py3-none-any.whl
```

### 2. Add to INSTALLED_APPS

> **Important:** `dj_auth` must appear **before** `allauth` and all `allauth.*` entries.
> Django's template loader searches apps in order — if allauth comes first, its own
> unstyled built-in templates shadow dj_auth's styled overrides. `manage.py check`
> will warn you (`dj_auth.W004`) if the ordering is wrong.

```python
INSTALLED_APPS = [
    # ... Django built-ins ...
    "django.contrib.sites",       # required by allauth

    "dj_auth",                    # must come before allauth

    "allauth",
    "allauth.account",
    "allauth.socialaccount",      # optional: social login
    "allauth.mfa",                # optional: MFA / passkeys

    # ... your apps ...
]
```

> `allauth.socialaccount` and `allauth.mfa` are genuinely optional: the login
> and signup pages detect whether they are installed and simply omit the
> social-login buttons / passkey button when they're not. `manage.py check`
> emits a warning (`dj_auth.W001` / `dj_auth.W002`) so you know the feature
> is unavailable.

### 3. Configure MIDDLEWARE

Add `AccountMiddleware` after `SessionMiddleware`:

```python
MIDDLEWARE = [
    ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    ...
    "allauth.account.middleware.AccountMiddleware",
]
```

### 4. Configure AUTHENTICATION_BACKENDS

```python
AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",           # keep for admin
    "allauth.account.auth_backends.AuthenticationBackend", # required by allauth
]
```

### 5. Include URLs

```python
# config/urls.py
from django.urls import include, path

urlpatterns = [
    path("accounts/", include("dj_auth.urls")),
    # ... your other URLs ...
]
```

### 6. Run migrations

```bash
python manage.py migrate
```

That's it. Visit `/accounts/login/` to see the login page.

## Configuration

`dj_auth` injects sensible defaults on startup via `AppConfig.ready()`. These are standard allauth and Django settings — no special `DJ_AUTH_*` namespace. Override any of them in your project's `settings.py` using the normal allauth setting names.

| Setting | Default | Description |
|---|---|---|
| `ACCOUNT_LOGIN_METHODS` | `{"email"}` | Login with email only (no username) |
| `ACCOUNT_SIGNUP_FIELDS` | `["email*", "password1*", "password2*"]` | Signup form fields |
| `ACCOUNT_EMAIL_VERIFICATION` | `"optional"` | `"mandatory"`, `"optional"`, or `"none"` |
| `LOGIN_REDIRECT_URL` | `"/"` | Where to go after login |
| `LOGOUT_REDIRECT_URL` | `"/"` | Where to go after logout |
| `MFA_SUPPORTED_TYPES` | `["totp", "recovery_codes", "webauthn"]` | Enabled MFA methods |
| `MFA_PASSKEY_LOGIN_ENABLED` | `True` | Show passkey login button |
| `MFA_PASSKEY_SIGNUP_ENABLED` | `False` | Allow passkey signup (requires mandatory email verification) |
| `MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN` | `DEBUG` | Skip the passkey origin check, so passkeys work on `http://localhost`. On only under `DEBUG` |
| `SOCIALACCOUNT_PROVIDERS` | `{}` | Social provider credentials (see below) |
| `SITE_ID` | `1` | Django sites framework site ID |

## Template Overriding

`dj_auth` ships templates inside the package (`dj_auth/templates/`). Django's template loader checks project-level `DIRS` first, so you can override any template by placing a file at the same path in your project's template directory.

### Overriding base.html

The main layout is `base.html`. Key blocks:

| Block | Required | Purpose |
|---|---|---|
| `content` | **yes** | Main page content — all auth pages render here |
| `account_nav` | no | Where allauth's own account pages put the [account navigation](#account-navigation). Keep it, above `content` |
| `head_title` | no | Page `<title>` text |
| `stylesheet` | no | The `<link>` to dj_auth's stylesheet (see [Styling](#styling)) |
| `extra_head` | no | Additional `<head>` content (CSS links, meta tags) |
| `nav` | no | Entire navigation bar |
| `nav_logo_href` | no | Logo link href (default: `/`) |
| `nav_logo_content` | no | Logo icon + site name HTML |
| `nav_links` | no | Right-side nav items |
| `footer` | no | Page footer |
| `extra_body` | no | Scripts before `</body>` |

To customise the layout, create `templates/base.html` in your project (which takes
priority over the package's version via Django's `DIRS` setting). You can start from
scratch or copy `dj_auth/templates/base.html` from the package as a starting point.
Do **not** use `{% extends "base.html" %}` in your override — that's circular.

Example — minimal `base.html` keeping dj_auth's styles but adding your own nav:

```html
{# myproject/templates/base.html #}
{% load static %}<!DOCTYPE html>
<html lang="en">
<head>
  <title>{% block head_title %}My App{% endblock %}</title>
  {% block stylesheet %}<link rel="stylesheet" href="{% static 'dj_auth/dj_auth.css' %}">{% endblock %}
  {% block extra_head %}{% endblock %}
</head>
<body>
  <nav><!-- your nav here --></nav>
  <main>
    {% block account_nav %}{% endblock %}
    {% block content %}{% endblock %}
  </main>
  {% block extra_body %}{% endblock %}
</body>
</html>
```

### Overriding allauth's internal pages

allauth's built-in pages (TOTP setup, WebAuthn management, etc.) extend
`allauth/layouts/base.html`. To make them match your project's branding:

```html
{# myproject/templates/allauth/layouts/base.html #}
{% extends "base.html" %}
```

### Account navigation

The account-settings pages link to each other: Email, Password, Security (with
`allauth.mfa`), Connected accounts (once a social provider is usable) and Sessions (with
`allauth.usersessions`). So your own navigation needs one link, to any of them —
`{% url 'account_email' %}` is the usual choice — and the rest are reachable from there.
Without that link nothing in your project leads to `/accounts/2fa/`, which is where a
passkey is added.

The links are `account/snippets/manage_nav.html`. dj_auth's own pages include it in their
content. allauth's pages (change password, TOTP setup, passkey list, connections) get it
from `allauth/layouts/manage.html`, which fills the layout's `account_nav` block — so a
`base.html` of your own needs that block, or those pages render without the links.

To add a page of your own, override the snippet, extend its base, and include the snippet
at the top of your page:

```html
{# myproject/templates/account/snippets/manage_nav.html #}
{% extends "account/snippets/manage_nav_base.html" %}
{% block items_start %}
  {% url 'profile' as profile_url %}
  {% include "account/snippets/manage_nav_item.html" with href=profile_url label="Profile" %}
{% endblock %}
```

`items_end` is the matching block after dj_auth's own links.

## Styling

dj_auth ships a compiled stylesheet, `dj_auth/static/dj_auth/dj_auth.css` (about 27 KB),
holding exactly the Tailwind utilities its templates use. Both layouts link it, so the
auth screens are styled with no build step and nothing loaded from a third party.
`collectstatic` picks it up like any app's static files.

### Recolouring

The templates name one brand colour by role, `primary` (`bg-primary-600`,
`text-primary-700`, `focus:ring-primary-500`, …), never a Tailwind hue. The stylesheet
compiles those to CSS variables with Tailwind's indigo as the default, so recolouring is
plain CSS — no Tailwind needed. Set any of the stops 50–950:

```css
:root {
  --color-primary-50: #f4f9e6;
  --color-primary-500: #769826;
  --color-primary-600: #5b7a1a;  /* filled buttons: keep white text at 4.5:1 or better */
  --color-primary-700: #4a6414;  /* hover, and text on the light stops */
}
```

This works from any stylesheet on the page, loaded before or after dj_auth's: the
defaults sit in a cascade layer, and a declaration outside any layer always wins. The
neutral greys are `slate-*` and are not themeable.

### Using your own Tailwind build instead

If your project writes its own templates with Tailwind, don't load both stylesheets —
two builds on one page repeat the reset and every shared utility, and can disagree.
Build one stylesheet that covers both, and link it in place of dj_auth's:

1. Add dj_auth's templates to your build's sources. With Tailwind v4:
   ```css
   @source "<path-to-venv>/lib/python3.x/site-packages/dj_auth/templates";
   ```
   (`@source` does not expand a glob in a directory segment, so the Python version is
   literal.)
2. Define `primary` in your theme. Without it every `primary-*` class compiles to
   nothing and the auth screens lose their buttons and links — silently.
   ```css
   @theme {
     --color-primary-50: oklch(96.2% 0.018 272.314);
     /* … 100 through 900 … */
     --color-primary-950: oklch(25.7% 0.09 281.288);
   }
   ```
3. Override the `stylesheet` block to link your build — in your own `base.html`, or in
   any page that extends dj_auth's:
   ```html
   {% block stylesheet %}<link rel="stylesheet" href="{% static 'css/main.css' %}">{% endblock %}
   ```

The templates are written for Tailwind v4. `assets/dj_auth.src.css` in this repository
is the build dj_auth itself uses, including three v3-era defaults the templates rely on
(grey borders, grey placeholders, pointer buttons); copy those into your own build too.
`examples/basic` does all of this for its home page.

### Changing the templates

Edit a template, then rebuild the stylesheet with `bin/build-css.sh` (it downloads a
pinned Tailwind standalone binary on first run — no Node) and commit both. CI rebuilds
it and fails if the committed copy is stale.

## Social Providers

Login/signup buttons are rendered only for providers that are actually usable:
a provider whose `APP` has an empty `client_id` (e.g. credentials left blank in
your `.env`) is hidden automatically, so you can keep provider apps in
`INSTALLED_APPS` and placeholder config in settings without showing dead
buttons.

Google, Apple, Microsoft and GitHub get a branded button; any other provider gets a plain
"Continue with …" one. There is one button template, `account/snippets/provider_button.html`,
used on the login and signup pages (through `account/snippets/social_buttons.html`) and on
allauth's connections page, so override that to restyle a provider everywhere.

Each button is a small form that POSTs. allauth answers a GET to a provider's login URL
with a page asking the visitor to confirm (`SOCIALACCOUNT_LOGIN_ON_GET` is off by default,
so that a link on someone else's site cannot start a login), and a POST skips it. Because
forms do not nest, include `social_buttons.html` outside your page's own `<form>`.

allauth needs `django-allauth[socialaccount]` for most providers, Google and Apple
included: `pip install "django-allauth[mfa,socialaccount]"`.

### Google

```python
SOCIALACCOUNT_PROVIDERS = {
    "google": {
        "APP": {
            "client_id": "your-client-id",
            "secret": "your-client-secret",
            "key": "",
        },
        "SCOPE": ["profile", "email"],
        "AUTH_PARAMS": {"access_type": "online"},
    },
}
```

Add `"allauth.socialaccount.providers.google"` to `INSTALLED_APPS`.

### GitHub

```python
SOCIALACCOUNT_PROVIDERS = {
    "github": {
        "APP": {
            "client_id": "your-client-id",
            "secret": "your-client-secret",
            "key": "",
        },
        "SCOPE": ["user:email"],
    },
}
```

Add `"allauth.socialaccount.providers.github"` to `INSTALLED_APPS`.

### Apple

```python
SOCIALACCOUNT_PROVIDERS = {
    "apple": {
        "APP": {
            "client_id": "com.example.web",      # the Services ID
            "secret": "ABC123DEFG",              # the Key ID
            "key": "TEAM123456",                 # the Team ID
            "settings": {"certificate_key": "-----BEGIN PRIVATE KEY-----\n…"},  # the .p8
        },
    },
}
```

Add `"allauth.socialaccount.providers.apple"` to `INSTALLED_APPS`. Apple sends the
person's name only the first time they authorise your app, so make sure your signup
keeps it.

### Microsoft

```python
SOCIALACCOUNT_PROVIDERS = {
    "microsoft": {
        "APP": {
            "client_id": "your-application-id",
            "secret": "your-client-secret",
            "settings": {"tenant": "common"},    # work, school and personal accounts
        },
    },
}
```

Add `"allauth.socialaccount.providers.microsoft"` to `INSTALLED_APPS`. Unlike Google and
Apple, allauth's Microsoft provider does not mark the address as verified, so with
`ACCOUNT_EMAIL_VERIFICATION = "mandatory"` these users confirm it by email.

For other providers, see the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/).

## MFA / Passkeys

MFA is enabled by default when `allauth.mfa` is in `INSTALLED_APPS`. Users manage their security settings at `/accounts/2fa/`.

### Passkey login

Passkeys are enabled by default (`MFA_PASSKEY_LOGIN_ENABLED = True`). The login page shows a "Sign in with a passkey" button automatically.

### Passkey signup

Disabled by default because it requires mandatory email verification:

```python
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
MFA_PASSKEY_SIGNUP_ENABLED = True
```

### Production WebAuthn

`MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN` makes allauth skip its check of the origin a passkey
was used on, which is what lets passkeys work on `http://localhost`. dj_auth turns it on
only when `DEBUG` is on, so a production site needs no setting at all. If you set it
yourself and it is `True` with `DEBUG` off, `manage.py check` warns (`dj_auth.W007`).

Before 0.5.0 the default was `True` everywhere, with a note to turn it off in production.
A project that serves plain HTTP with `DEBUG` off (a local Docker stack on
`http://localhost`, say) and wants passkeys there now has to set it to `True` itself.

Users add a passkey at `/accounts/2fa/`; see [Account navigation](#account-navigation) for
how they get there.

## Internal node-to-node API

**Optional, and off unless configured.** A single-node project can ignore this entire
section.

Once a project splits across more than one node — one holding `auth_user`, others holding
tenant data — the other nodes need to ask the first one about users. `dj_auth.internal`
provides that: one endpoint, its client, and the machinery to keep both off the public
internet.

### The endpoint

```python
# urls.py — explicitly, and NOT under accounts/
urlpatterns = [
    path("accounts/", include("dj_auth.urls")),
    path("", include("dj_auth.internal.urls")),   # serves /internal/users/
]
```

```python
# settings.py, on the node that holds auth_user
DJ_AUTH_INTERNAL_API_TOKENS = [os.environ["INTERNAL_API_TOKEN"]]
```

Including the URLs with no token raises `ImproperlyConfigured` at startup rather than
serving user data behind nothing. The way to *not* have an internal API is to not include
the URLs.

`GET /internal/users/?email=…` or `?ids=1,2,3` returns `{"users": [{"id", "email",
"full_name"}]}`. `full_name` is `get_full_name()`, which under this package's email-only
signup defaults is usually empty — treat it as optional.

### The client, on the other nodes

```python
DJ_AUTH_INTERNAL_ORIGIN = "http://auth.internal:9000"
DJ_AUTH_INTERNAL_API_TOKENS = [os.environ["INTERNAL_API_TOKEN"]]
```

```python
from dj_auth.internal.client import find_user_by_email, get_users_by_ids, AuthNodeUnavailable

try:
    user = find_user_by_email("someone@example.com")   # InternalUser | None
    users = get_users_by_ids([1, 2, 3])                # list[InternalUser]
except AuthNodeUnavailable:
    ...   # network error, timeout, non-2xx, or invalid JSON
```

`call_auth_node(path, method=..., payload=...)` is exported too, so a project's own
node-to-node calls get the same origin, token and error handling without reimplementing them.

Tokens are a **list** so rotation is two deploys rather than one synchronised one: add the
new token everywhere, then remove the old. A caller presents the first; the server accepts
any. Comparison is constant-time.

### Keeping it off the public internet

This is the part most likely to be got wrong, so it is worth reading even if the rest looked
obvious. Add the middleware **first**:

```python
MIDDLEWARE = [
    "dj_auth.internal.middleware.InternalRouteMiddleware",
    ...
]
```

It answers **404** — not 403 — to anything under `/internal/` that did not arrive by the
internal route, and it does so before the token is examined, so nothing about token state is
observable from the wrong route. There are two independent checks, and **neither is
sufficient in every environment**:

| Setting | Use it when | Boundary or backstop |
|---|---|---|
| `DJ_AUTH_INTERNAL_PORT` | The internal port is genuinely unpublished — e.g. a Docker Compose port absent from `ports:` | **The boundary** there |
| `DJ_AUTH_INTERNAL_CLIENT_NETWORKS` | The internal port *is* published on a public address — the normal case behind a platform proxy | **The boundary** there |

Configuring both is usually right. Bind the app to a second port and point the internal
origin at it:

```
gunicorn ... --bind :8000 --bind :9000
```

```python
DJ_AUTH_INTERNAL_PORT = "9000"                       # from SERVER_PORT, unforgeable
DJ_AUTH_INTERNAL_CLIENT_NETWORKS = ["10.0.0.0/8"]    # CIDRs callers must come from
DJ_AUTH_INTERNAL_CLIENT_IP_HEADER = ""               # empty = use REMOTE_ADDR
```

`DJ_AUTH_INTERNAL_CLIENT_IP_HEADER` names a header to trust for the client address, needed
when a proxy sits in the path and `REMOTE_ADDR` is therefore the proxy. **Set it only where a
proxy always overwrites that header.** On an app reachable without that proxy in front, a
trusted header is client-supplied input — which is exactly why a `Host` allowlist is not used
here. An absent or unparseable address fails closed.

#### Fly.io: the port check is not a boundary

Worth stating plainly, because it is counter-intuitive and it fails silently.

To reach a second port over [Flycast](https://fly.io/docs/networking/flycast/) — which is
what lets private traffic wake a suspended machine — you add a `[[services]]` block for it.
But Fly publishes a service's ports on **every** address the app holds, and its own
documentation is explicit:

> If you have public IP addresses assigned to your app, then services in `fly.toml` are
> exposed to the public internet.

There is no per-service private-only flag. So the block that makes port 9000 reachable
privately makes it reachable publicly, and such a request **passes the port check by
construction** — it really did arrive on the internal port. Meanwhile `/internal/` on 443
returns a tidy 404, so it all looks like it is working.

On Fly, use the client-network check:

```toml
[env]
  DJ_AUTH_INTERNAL_PORT = '9000'                       # still worth setting; defence in depth
  DJ_AUTH_INTERNAL_CLIENT_NETWORKS = 'fdaa::/16'       # Fly's 6PN range: private, never public
  DJ_AUTH_INTERNAL_CLIENT_IP_HEADER = 'Fly-Client-IP'  # the proxy overwrites this; verified
```

`fdaa::/16` covers every 6PN address and nothing public. `Fly-Client-IP` is safe to trust
here specifically because Fly Proxy overwrites whatever a client sends — confirmed against a
live deploy, with forged values including an unparseable one, all discarded.

### System checks

`manage.py check` catches the configurations that otherwise fail quietly:

| ID | Meaning |
|---|---|
| `dj_auth.E006` | Internal API enabled with no route restriction while `DEBUG=False` |
| `dj_auth.E007` | A restriction is configured but the middleware is missing, so nothing enforces it |
| `dj_auth.W005` | `CLIENT_IP_HEADER` set with no `CLIENT_NETWORKS`, so the header is read by nothing |
| `dj_auth.W006` | Tokens that look like placeholders, or are under 32 characters, while `DEBUG=False` |

---

## Running the Example App

```bash
cd examples/basic
pip install -e ../../        # install dj-auth from source
pip install -r requirements.txt
cp .env.example .env         # edit as needed
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
```

Visit http://localhost:8000 to see the example app.

## Running the Test Suite

```bash
pip install -e ".[dev]"
pytest
```

Or with tox (tests against multiple Django versions):

```bash
tox
```

To run just the lint checks:

```bash
tox -e lint
# or
ruff check dj_auth tests
```
