📄 auth.py
/home/palash/git/local-ai/server/auth.py
Language: py • Lines: 233
"""Unified RBAC / SSO identity layer backed by Authentik.

This is the SINGLE identity provider for every app on the box. There is no
more ``users.json``; users, passwords and roles live in Authentik.

Two identity paths converge here:

1. Browser users — nginx runs ``auth_request`` against the Authentik proxy
   outpost and forwards the ``X-Authentik-*`` claim headers upstream. Backends
   trust those headers (only nginx can reach the app ports).

2. Machine agents (self-chat.py) — obtain an OIDC access token via Authentik's
   OAuth2 password grant and send it as ``Authorization: Bearer <jwt>``.
   Backends verify the JWT signature against Authentik's JWKS.

The resolved identity is always a dict::

    {
        "username": str,
        "email": str,
        "name": str,
        "groups": [str, ...],   # raw Authentik group names
        "role": "admin" | "premium" | "free",
        "uid": str,             # Authentik user UUID (unique id across renames)
    }
"""

import re
import threading
import time

import jwt
from jwt import PyJWK
import requests

from server.config import (
    AUTH_AGENTS_CLIENT_ID,
    AUTH_AGENTS_CLIENT_SECRET,
    AUTH_AGENTS_ISSUER,
    AUTH_AGENTS_JWKS_URL,
    AUTH_AGENTS_TOKEN_URL,
    AUTH_ROLE_GROUPS,
)

# Highest role wins when a user belongs to several groups.
_ROLE_LEVEL = {"free": 0, "premium": 1, "admin": 2}

_jwks_cache = None
_jwks_cache_at = 0.0
_jwks_lock = threading.Lock()
_JWKS_TTL = 300


def _first(seq):
    for item in seq or ():
        if item:
            return item
    return None


def role_from_groups(groups):
    """Map Authentik group names to the app role scale (free/premium/admin)."""
    best = "free"
    best_level = -1
    for g in groups or ():
        role = AUTH_ROLE_GROUPS.get(g.lower().strip())
        if role and _ROLE_LEVEL.get(role, -1) > best_level:
            best = role
            best_level = _ROLE_LEVEL[role]
    return best


def _split_groups(raw):
    if not raw:
        return []
    if isinstance(raw, (list, tuple, set)):
        return [str(g).strip() for g in raw if str(g).strip()]
    return [g.strip() for g in re.split(r"[|,\s]+", str(raw)) if g.strip()]


def identity_from_headers(headers):
    """Resolve identity from the nginx-injected X-Authentik-* claim headers.

    These headers are set by nginx's auth_request subrequest against the
    Authentik proxy outpost and are only present on nginx-fronted traffic.
    Returns None when absent (e.g. direct localhost calls or agents).
    """
    username = _first(
        [headers.get("X-Authentik-Username"), headers.get("X-Authentik-User")]
    )
    if not username:
        return None
    groups = _split_groups(
        _first(
            [
                headers.get("X-Authentik-Groups"),
                headers.get("X-Authentik-Group"),
            ]
        )
    )
    return {
        "username": username,
        "email": headers.get("X-Authentik-Email", ""),
        "name": headers.get("X-Authentik-Name", ""),
        "groups": groups,
        "role": role_from_groups(groups),
        "uid": headers.get("X-Authentik-UID", ""),
    }


def _fetch_jwks():
    """Return the Authentik JWKS key set (cached for _JWKS_TTL seconds)."""
    global _jwks_cache, _jwks_cache_at
    with _jwks_lock:
        now = time.time()
        if _jwks_cache is not None and now - _jwks_cache_at < _JWKS_TTL:
            return _jwks_cache
        if not AUTH_AGENTS_JWKS_URL:
            return []
        try:
            resp = requests.get(AUTH_AGENTS_JWKS_URL, timeout=5)
            resp.raise_for_status()
            _jwks_cache = resp.json().get("keys", [])
            _jwks_cache_at = now
        except (requests.RequestException, ValueError):
            _jwks_cache = _jwks_cache or []
            _jwks_cache_at = now
        return _jwks_cache


def identity_from_bearer(authorization):
    """Verify an ``Authorization: Bearer <jwt>`` token and resolve its identity.

    Verifies the token signature against Authentik's JWKS and enforces the
    issuer (the Authentik OIDC provider URL). Returns None for expired, bogus
    or missing tokens. Raises on transient network failures (so callers can
    treat those distinctly from a plain "no identity").
    """
    if not authorization or not authorization.lower().startswith("bearer "):
        return None
    token = authorization.split(" ", 1)[1].strip()
    if not token:
        return None
    keys = _fetch_jwks()
    if not keys:
        raise RuntimeError("Authentik JWKS unavailable — cannot verify access token")

    decoded = None
    for key in keys:
        try:
            crypto_key = PyJWK.from_dict(key).key
            decoded = jwt.decode(
                token,
                crypto_key,
                algorithms=[key.get("alg", "RS256")],
                issuer=AUTH_AGENTS_ISSUER,
                options={"verify_aud": False},
            )
            break
        except jwt.InvalidTokenError:
            continue
    if not decoded:
        return None

    username = _first(
        [
            decoded.get("preferred_username"),
            decoded.get("username"),
            decoded.get("email"),
            decoded.get("sub"),
        ]
    )
    groups = _split_groups(
        _first([decoded.get("groups"), decoded.get("ak_groups")])
    )
    return {
        "username": username,
        "email": decoded.get("email", ""),
        "name": decoded.get("name", ""),
        "groups": groups,
        "role": role_from_groups(groups),
        "uid": decoded.get("sub", ""),
    }


def get_identity(headers):
    """Resolve identity from request headers (browser path + agent path)."""
    identity = identity_from_headers(headers)
    if identity:
        return identity
    return identity_from_bearer(headers.get("Authorization", ""))


def get_current_user(headers):
    """Return just the authenticated username (or None)."""
    identity = get_identity(headers)
    return identity["username"] if identity else None


def required_role_level(role):
    """Level for the given role name (free=0, premium=1, admin=2)."""
    return _ROLE_LEVEL.get(role, 0)


def oidc_password_grant(username, password):
    """Exchange agent credentials for an Authentik OIDC access token.

    Used by self-chat.py so the automated agents authenticate through the same
    identity provider as humans. Returns the access token string. Raises on any
    failure so callers can surface a clear error.
    """
    if not AUTH_AGENTS_TOKEN_URL or not AUTH_AGENTS_CLIENT_ID:
        raise RuntimeError(
            "Authentik OIDC not configured (AUTH_AGENTS_TOKEN_URL/AUTH_AGENTS_CLIENT_ID)"
        )
    resp = requests.post(
        AUTH_AGENTS_TOKEN_URL,
        data={
            "grant_type": "password",
            "username": username,
            "password": password,
            "client_id": AUTH_AGENTS_CLIENT_ID,
            "client_secret": AUTH_AGENTS_CLIENT_SECRET,
            "scope": "openid profile email groups",
        },
        timeout=15,
    )
    resp.raise_for_status()
    data = resp.json()
    access_token = data.get("access_token")
    if not access_token:
        raise RuntimeError(f"Authentik password grant returned no access token: {data}")
    return access_token