Personal finance aggregator: Handing authentication to identity-system - Part 9

Personal finance aggregator: Handing authentication to identity-system - Part 9

Building Finance System

~8 min read

Post 6 ended with a login system I was reasonably pleased with: Argon2id passwords, opaque server-side sessions, TOTP two-factor with recovery codes, a password-change form that borrowed login() to verify the current password. It had also, by the end of that post, hit two gaps in the library underneath it - no way to update a user's name or email, and no built-in current-password check - that I'd worked around rather than fixed.

The next day I deleted all of it. This post is about why that was the right call, and what replaced it: a pair of commits that moved authentication out of this app entirely.

The real problem wasn't the code, it was the count

Everything in post 6 was a sensible way to run authentication for one application. But this isn't the only thing I run at home. Each small tool that needed a login would have had to carry its own copy of the same machinery - its own user table, its own 2FA enrolment screen, its own password-change form - and I'd be the only person who'd ever remember how each one differed. The gaps from post 6 were a preview of that: any fix to how accounts work would have to be made once in a library and then rolled out to every app that embedded it.

The alternative is the pattern nginx calls auth_request: put a single service in front of every app, let it own accounts, and have the app trust whatever identity that service vouches for. That service is a separate project I called identity-system. This app's job shrinks from "authenticate users" to "believe the headers nginx hands me, and make sure those headers can't be forged."

Step one: put the gate in front, keep the old door open

The first commit (Gate the app behind identity-system's forward-auth), only touched two files: deploy/nginx.conf and src/finance_system/auth.py. It was deliberately additive - the old cookie login kept working so I could verify the new path without locking myself out.

In nginx, every request is first checked against identity-system:

# Internal-only: reached via auth_request below, never hit directly.
location = /_verify {
    internal;
    proxy_pass http://192.168.71.29/verify;
    proxy_set_header Host identity.atathome.me;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
}

location /api/ {
    auth_request /_verify;
    auth_request_set $auth_user_id $upstream_http_x_auth_user_id;
    auth_request_set $auth_user_email $upstream_http_x_auth_user_email;
    auth_request_set $auth_user_name $upstream_http_x_auth_user_name;

    error_page 401 = @api_unauthorized;

    proxy_pass http://127.0.0.1:8000;
    proxy_set_header X-Auth-User-Id $auth_user_id;
    proxy_set_header X-Auth-User-Email $auth_user_email;
    proxy_set_header X-Auth-User-Name $auth_user_name;
}

Two details in there matter more than the rest. First, the proxy_set_header lines overwrite the X-Auth-User-* headers unconditionally. If a client sends its own X-Auth-User-Email: someone-else@example.com, nginx replaces it with whatever identity-system actually returned - a forwarded-header scheme is only as trustworthy as that overwrite. Second, the two failure modes are different on purpose. An API call that fails the check gets a bare 401 with a JSON body, because the frontend's fetch() calls expect JSON. A page load that fails gets a 302 to identity-system's /login with a rd= parameter carrying the URL to come back to, because that's a human in a browser.

On the Python side, current_user() learned to prefer those headers and fall back to the cookie session when they weren't there - which is what kept local development and the existing /api/auth/* routes alive in the interim.

Step two: remove everything that's now redundant

With the gate verified, the second commit (Retire local login/2FA/password now that identity-system fronts auth) did the satisfying part. The diffstat tells it: 22 files, 94 insertions, 897 deletions.

On the backend, current_user() became purely header-based:

def current_user(request: Request) -> AuthenticatedUser:
    email = request.headers.get("x-auth-user-email")
    user_id = request.headers.get("x-auth-user-id")
    if not email or not user_id:
        raise AuthenticationError()
    return AuthenticatedUser(
        id=user_id,
        email=email,
        name=request.headers.get("x-auth-user-name") or email,
    )

A missing header means the app was reached directly, bypassing nginx, and gets a 401 - there's deliberately no fallback to a default user. (That second user_id check arrived a few commits later, for reasons that make more sense in post 10.) /api/auth/* shrank to a single /me endpoint that just reflects the forwarded identity back to the frontend. sessionkit came out of pyproject.toml and uv.lock entirely, along with the exception handlers and schemas that existed only to translate its errors.

On the frontend, LoginForm, PasswordSettings and TwoFactorSettings were deleted outright, along with their tests. The Settings page now just points at identity-system for account management. Two behaviours changed in ways worth noting:

  • An unauthenticated response sends the browser to identity-system. If /api/auth/me fails, App.tsx redirects to identity-system's /login with the current URL as rd. In production that shouldn't ever fire, since nginx would already have redirected the page load - it's a backstop for the case where nginx's check was somehow bypassed.
  • Logging out is a form POST, not a fetch(). identity-system's session cookie lives on its own origin, and its POST /logout responds with a redirect to its login page. A fetch() can't follow a cross-origin redirect usefully, so logout creates a real <form method="POST">, points it at identity-system, and submits it - a top-level navigation, which can.

What this cost, and what it bought

The honest cost is local development. There's no login page to click through any more, and neither the FastAPI dev server nor Vite sits behind nginx, so every request 401s by default. docs/development.md now says so plainly: for backend work, send the headers yourself with curl; for the full frontend, run identity-system alongside with the same auth_request wiring. A later commit's message mentions driving the app with Playwright "auth headers faked via route interception, since there's no login page anymore" - that's what working this way actually looks like.

What it bought was the deletion of roughly 800 lines whose only job was to be a second, worse copy of something that now exists once. Password hashing, session lifetime, two-factor enrolment and recovery codes are one service's problem, fixed in one place. This app is left with a rule short enough to state in a sentence: the identity on the request is whatever nginx says it is, and everything else keys off the user id.

That last clause turned out to matter more than I expected. Once the app knew who was asking, there was a better question to ask about where each person's data lives.


Previous: ← Account balances and disconnecting banks · Next: One storage tree per user →