view mrjunejune/auth_api.c @ 272:41a49c29a28f

polish JRPG conversation experience Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 16:05:29 -0700
parents 04fee26ecce0
children
line wrap: on
line source

#include "mrjunejune/auth_api.h"
#include "mrjunejune/template_renderer.h"

#include "auth/auth_crypto.h"
#include "auth/auth_store.h"
#include "seobeo/seobeo.h"
#include "dowa/dowa.h"

#include <openssl/hmac.h>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/crypto.h>

#include <arpa/inet.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>

/* ------------------------------------------------------------------ */
/* Constants                                                            */
/* ------------------------------------------------------------------ */

#define BODY_MAX_BYTES          4096
#define COOKIE_VALUE_MAX        512
#define CSRF_SUFFIX             ":csrf:v1"
#define RATE_TABLE_SIZE         512   /* must be power of 2 */
#define RATE_PROBE_LIMIT        16
#define RATE_LIMIT_MAX_FAILURES 5
#define RATE_LIMIT_WINDOW_SECS  (15 * 60)
#define SESSION_COOKIE_MAX      512
#define GUEST_COOKIE_MAX        (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256)
#define RATE_KEY_MAX            65
#define BINDING_INPUT_MAX       (AUTH_CRYPTO_TOKEN_DIGEST_SIZE + 16)

/*
 * Fixed precomputed scrypt hash of the constant string "dummy-zenbu-timing".
 * Used only for timing-attack mitigation on nonexistent-username lookups.
 * Never used as an account credential; salt+hash are intentionally public.
 */
#define AUTH_DUMMY_PASSWORD_HASH \
    "zenbu-scrypt$v=1$N=32768$r=8$p=1$" \
    "c4ff27cc756429b17991b80e4c2c5f77$" \
    "34e208b3a51796ebd3b39da1039611b1c7ffeb04090b07ef2bf4968ce8131ce3"

/* ------------------------------------------------------------------ */
/* Module state                                                         */
/* ------------------------------------------------------------------ */

static Auth_Store              *g_auth_store         = NULL;
static uint8                    g_cookie_secret[AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES];
static size_t                   g_cookie_secret_length = 0;
static char                     g_trusted_proxy_ip[AUTH_CRYPTO_IP_MAX_BYTES];
static boolean                  g_has_trusted_proxy  = FALSE;
static int64                    g_session_idle_ttl   = AUTH_API_SESSION_IDLE_TTL_DEFAULT;
static int64                    g_session_abs_ttl    = AUTH_API_SESSION_ABS_TTL_DEFAULT;
static int64                    g_guest_ttl          = AUTH_API_GUEST_TTL_DEFAULT;
static boolean                  g_dev_insecure_cookie = FALSE;
static Auth_Guest_Transfer_Hook g_transfer_hook      = NULL;
static void                    *g_transfer_hook_ctx  = NULL;
static Auth_API_Guest_Quota_Cb  g_guest_quota_cb     = NULL;
#ifdef AUTH_API_TEST_HOOKS
static Auth_API_Test_Login_Pre_Create_Hook g_login_pre_create_hook = NULL;
static void *g_login_pre_create_context = NULL;
#endif

/* ------------------------------------------------------------------ */
/* Rate limiter (collision-safe open-addressing with LRU eviction)      */
/* ------------------------------------------------------------------ */

typedef struct {
  char     key[RATE_KEY_MAX]; /* HMAC hex binding; empty if slot unused */
  uint32   count;
  int64    window_start;
} Auth_Rate_Entry;

static Auth_Rate_Entry  g_rate_table[RATE_TABLE_SIZE];
static pthread_mutex_t  g_rate_mutex = PTHREAD_MUTEX_INITIALIZER;

/* ------------------------------------------------------------------ */
/* Internal helpers                                                     */
/* ------------------------------------------------------------------ */

static int64 auth_now(void)
{
  return (int64)time(NULL);
}

/*
 * Generate a UUID v4 using RAND_bytes.
 * buf must be at least 37 bytes.
 */
static boolean auth_uuid4(char *buf, size_t capacity)
{
  if (capacity < 37) return FALSE;
  uint8 rnd[16];
  if (RAND_bytes(rnd, sizeof(rnd)) != 1) return FALSE;
  /* RFC 4122 version 4 */
  rnd[6] = (rnd[6] & 0x0f) | 0x40;
  rnd[8] = (rnd[8] & 0x3f) | 0x80;
  snprintf(buf, capacity,
           "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-"
           "%02x%02x%02x%02x%02x%02x",
           rnd[0],  rnd[1],  rnd[2],  rnd[3],
           rnd[4],  rnd[5],
           rnd[6],  rnd[7],
           rnd[8],  rnd[9],
           rnd[10], rnd[11], rnd[12], rnd[13], rnd[14], rnd[15]);
  OPENSSL_cleanse(rnd, sizeof(rnd));
  return TRUE;
}

/*
 * base64url encode src_len bytes from src into dst.
 * dst must have capacity ceil(src_len * 4 / 3) + 1.
 * Returns length of encoded string (without NUL).
 *
 * Delegates to Auth_Crypto_Base64url_Encode which uses the correct loop
 * boundary (processes remaining bytes after full 3-byte groups).
 */
static size_t auth_base64url_encode(
    const uint8 *src,
    size_t        src_len,
    char         *dst,
    size_t        dst_capacity)
{
  return Auth_Crypto_Base64url_Encode(src, src_len, dst, dst_capacity);
}

static void auth_hex_encode(
    const uint8 *src,
    size_t        src_len,
    char         *dst,
    size_t        dst_capacity)
{
  static const char kHex[] = "0123456789abcdef";
  size_t i = 0, o = 0;
  while (i < src_len && o + 2 < dst_capacity)
  {
    dst[o++] = kHex[(src[i] >> 4) & 0xf];
    dst[o++] = kHex[src[i] & 0xf];
    i++;
  }
  if (o < dst_capacity) dst[o] = '\0';
}

/*
 * Derive a CSRF token deterministically from a session binding.
 * binding: session token_digest (user) or guest_id (guest).
 * Output: base64url-encoded HMAC-SHA256, AUTH_CRYPTO_TOKEN_SIZE bytes.
 */
static boolean auth_derive_csrf(
    const char *binding,
    char       *csrf_out,
    size_t      csrf_capacity)
{
  if (!binding || !csrf_out || csrf_capacity < AUTH_CRYPTO_TOKEN_SIZE)
    return FALSE;

  size_t binding_len = strlen(binding);
  size_t suffix_len  = strlen(CSRF_SUFFIX);
  size_t input_len   = binding_len + suffix_len;

  if (input_len >= BINDING_INPUT_MAX)
    return FALSE;

  char input[BINDING_INPUT_MAX];
  memcpy(input, binding, binding_len);
  memcpy(input + binding_len, CSRF_SUFFIX, suffix_len);

  uint8 digest[32];
  uint32 digest_len = 32;
  if (!HMAC(EVP_sha256(),
            g_cookie_secret, (int)g_cookie_secret_length,
            (const uint8 *)input, input_len,
            digest, &digest_len))
  {
    OPENSSL_cleanse(input, sizeof(input));
    OPENSSL_cleanse(digest, sizeof(digest));
    return FALSE;
  }
  OPENSSL_cleanse(input, sizeof(input));

  size_t encoded_length =
      auth_base64url_encode(digest, 32, csrf_out, csrf_capacity);
  OPENSSL_cleanse(digest, sizeof(digest));
  return encoded_length == AUTH_CRYPTO_TOKEN_SIZE - 1;
}

/*
 * Build the rate-limit key: HMAC(secret, peer_digest + ":" + norm_user).
 * Output: 64-char hex string.
 */
static boolean auth_rate_key(
    const char *peer_binding_digest,
    const char *normalized_username,
    char       *key_out)
{
  char input[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE + 1 + AUTH_STORE_USERNAME_MAX + 1];
  int n = snprintf(input, sizeof(input), "%s:%s",
                   peer_binding_digest, normalized_username);
  if (n < 0 || (size_t)n >= sizeof(input))
    return FALSE;

  uint8 digest[32];
  uint32 digest_len = 32;
  if (!HMAC(EVP_sha256(),
            g_cookie_secret, (int)g_cookie_secret_length,
            (const uint8 *)input, (size_t)n,
            digest, &digest_len))
  {
    OPENSSL_cleanse(input, sizeof(input));
    OPENSSL_cleanse(digest, sizeof(digest));
    return FALSE;
  }
  OPENSSL_cleanse(input, sizeof(input));

  auth_hex_encode(digest, 32, key_out, RATE_KEY_MAX);
  OPENSSL_cleanse(digest, sizeof(digest));
  return TRUE;
}

static uint32 auth_rate_index(const char *key)
{
  /* Use the first 8 hex chars of the HMAC key as a uint32 hash seed. */
  uint32 h = 0;
  for (int i = 0; i < 8 && key[i] != '\0'; i++)
  {
    char c = key[i];
    uint32 nibble = (c >= '0' && c <= '9') ? (uint32)(c - '0') :
                    (c >= 'a' && c <= 'f') ? (uint32)(c - 'a' + 10) : 0;
    h = (h << 4) | nibble;
  }
  return h & (RATE_TABLE_SIZE - 1);
}

/*
 * Find or insert an entry for key in the open-addressing rate table.
 * Probes up to RATE_PROBE_LIMIT slots from the hash index.
 * On a full probe window, evicts the oldest entry by window_start.
 * Must be called with g_rate_mutex held.
 * Returns a pointer to the entry on success, NULL if internal error.
 */
static Auth_Rate_Entry *auth_rate_find_or_insert(const char *key, int64 now)
{
  uint32 start = auth_rate_index(key);
  Auth_Rate_Entry *evict_candidate = NULL;
  int64 evict_time = INT64_MAX;

  for (uint32 i = 0; i < (uint32)RATE_PROBE_LIMIT; i++)
  {
    uint32 idx = (start + i) & (RATE_TABLE_SIZE - 1);
    Auth_Rate_Entry *e = &g_rate_table[idx];

    /* Exact match */
    if (e->key[0] != '\0' &&
        memcmp(e->key, key, RATE_KEY_MAX) == 0)
      return e;

    /* Empty slot — claim it */
    if (e->key[0] == '\0')
    {
      memcpy(e->key, key, RATE_KEY_MAX);
      e->count        = 0;
      e->window_start = now;
      return e;
    }

    /* Expired entry — reuse it immediately */
    if (now - e->window_start >= RATE_LIMIT_WINDOW_SECS)
    {
      memcpy(e->key, key, RATE_KEY_MAX);
      e->count        = 0;
      e->window_start = now;
      return e;
    }

    /* Track the oldest live entry for eviction */
    if (e->window_start < evict_time)
    {
      evict_time      = e->window_start;
      evict_candidate = e;
    }
  }

  /* All probe slots occupied by live, non-matching entries — evict oldest */
  if (evict_candidate)
  {
    memcpy(evict_candidate->key, key, RATE_KEY_MAX);
    evict_candidate->count        = 0;
    evict_candidate->window_start = now;
    return evict_candidate;
  }

  return NULL;
}

/*
 * Returns TRUE if the caller should be rate-limited (too many failures).
 * Caller must still call auth_rate_record_failure on a failed attempt.
 */
static boolean auth_rate_check(const char *key)
{
  pthread_mutex_lock(&g_rate_mutex);
  int64 now = auth_now();
  Auth_Rate_Entry *entry = auth_rate_find_or_insert(key, now);
  boolean limited = FALSE;
  if (entry && now - entry->window_start < RATE_LIMIT_WINDOW_SECS)
    limited = (entry->count >= RATE_LIMIT_MAX_FAILURES);
  pthread_mutex_unlock(&g_rate_mutex);
  return limited;
}

static void auth_rate_record_failure(const char *key)
{
  pthread_mutex_lock(&g_rate_mutex);
  int64 now = auth_now();
  Auth_Rate_Entry *entry = auth_rate_find_or_insert(key, now);
  if (entry)
  {
    if (now - entry->window_start >= RATE_LIMIT_WINDOW_SECS)
    {
      /* Window expired; start fresh */
      entry->count        = 1;
      entry->window_start = now;
    }
    else
    {
      entry->count++;
    }
  }
  pthread_mutex_unlock(&g_rate_mutex);
}

static void auth_rate_reset(const char *key)
{
  pthread_mutex_lock(&g_rate_mutex);
  uint32 start = auth_rate_index(key);
  for (uint32 i = 0; i < (uint32)RATE_PROBE_LIMIT; i++)
  {
    uint32 idx = (start + i) & (RATE_TABLE_SIZE - 1);
    Auth_Rate_Entry *e = &g_rate_table[idx];
    if (e->key[0] != '\0' && memcmp(e->key, key, RATE_KEY_MAX) == 0)
    {
      memset(e, 0, sizeof(*e));
      break;
    }
  }
  pthread_mutex_unlock(&g_rate_mutex);
}

/* ------------------------------------------------------------------ */
/* Request helpers                                                      */
/* ------------------------------------------------------------------ */

static const char *auth_req_value(
    Seobeo_Request_Entry *p_req,
    const char           *key)
{
  void *p = Dowa_HashMap_Get_Ptr(p_req, (char *)key);
  return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
}

static boolean auth_extract_secret_field(
    Dowa_JSON_Entry *obj,
    const char      *key,
    char            *out_buf,
    size_t           max_len)
{
  char *arena_ptr = Dowa_JSON_Get_String(obj, key);
  if (!arena_ptr || arena_ptr[0] == '\0')
    return FALSE;

  size_t field_len = strlen(arena_ptr);
  if (field_len > max_len)
  {
    OPENSSL_cleanse(arena_ptr, field_len);
    return FALSE;
  }

  memcpy(out_buf, arena_ptr, field_len);
  out_buf[field_len] = '\0';
  OPENSSL_cleanse(arena_ptr, field_len);
  return TRUE;
}

/*
 * Parse a named cookie from the Cookie header.
 * Returns TRUE and fills value_out on success.
 */
static boolean auth_parse_cookie(
    const char *cookie_header,
    const char *name,
    char       *value_out,
    size_t      capacity)
{
  if (!cookie_header || !name || !value_out || capacity == 0)
    return FALSE;

  size_t name_len = strlen(name);
  const char *p = cookie_header;

  while (*p)
  {
    /* skip whitespace */
    while (*p == ' ' || *p == '\t') p++;

    /* check for name= */
    if (strncmp(p, name, name_len) == 0 && p[name_len] == '=')
    {
      p += name_len + 1;
      const char *start = p;
      while (*p && *p != ';') p++;
      size_t vlen = (size_t)(p - start);
      if (vlen >= capacity) return FALSE;
      memcpy(value_out, start, vlen);
      value_out[vlen] = '\0';
      return TRUE;
    }

    /* skip to next ; */
    while (*p && *p != ';') p++;
    if (*p == ';') p++;
  }
  return FALSE;
}

/*
 * Resolve effective peer IP.
 * If Remote-Addr matches configured trusted proxy, accept X-Real-IP.
 * Never logs raw IPs.
 */
static boolean auth_peer_ip(
    Seobeo_Request_Entry *p_req,
    char                 *ip_out,
    size_t                capacity)
{
  const char *direct = auth_req_value(p_req, "Remote-Addr");
  if (!direct || direct[0] == '\0')
    return FALSE;

  if (g_has_trusted_proxy &&
      strcmp(direct, g_trusted_proxy_ip) == 0)
  {
    const char *forwarded = auth_req_value(p_req, "X-Real-IP");
    if (forwarded && forwarded[0] != '\0' && strlen(forwarded) < capacity)
    {
      strncpy(ip_out, forwarded, capacity - 1);
      ip_out[capacity - 1] = '\0';
      return TRUE;
    }
  }

  if (strlen(direct) >= capacity)
    return FALSE;
  strncpy(ip_out, direct, capacity - 1);
  ip_out[capacity - 1] = '\0';
  return TRUE;
}

static boolean auth_same_origin(Seobeo_Request_Entry *p_req)
{
  const char *host   = auth_req_value(p_req, "Host");
  const char *origin = auth_req_value(p_req, "Origin");
  if (!host || !origin) return FALSE;

  const char *host_in_origin = strstr(origin, "://");
  if (!host_in_origin) return FALSE;
  host_in_origin += 3;

  const char *end = strchr(host_in_origin, '/');
  size_t len = end ? (size_t)(end - host_in_origin) : strlen(host_in_origin);
  return strlen(host) == len && strncmp(host, host_in_origin, len) == 0;
}

/* ------------------------------------------------------------------ */
/* Response builders                                                    */
/* ------------------------------------------------------------------ */

/*
 * Build a Set-Cookie directive string.
 * expires_max_age = 0 means no Max-Age (persistent); < 0 means Max-Age=0
 * (clear the cookie).
 */
static boolean auth_build_cookie_directive(
    const char *name,
    const char *value,
    int32       max_age,
    boolean     http_only,
    char       *out,
    size_t      capacity)
{
  int n;
  if (max_age < 0)
  {
    n = snprintf(out, capacity,
                 "%s=; Path=/; %sSameSite=Lax; Max-Age=0%s",
                 name,
                 http_only ? "HttpOnly; " : "",
                 g_dev_insecure_cookie ? "" : "; Secure");
  }
  else if (max_age == 0)
  {
    n = snprintf(out, capacity,
                 "%s=%s; Path=/; %sSameSite=Lax%s",
                 name, value,
                 http_only ? "HttpOnly; " : "",
                 g_dev_insecure_cookie ? "" : "; Secure");
  }
  else
  {
    n = snprintf(out, capacity,
                 "%s=%s; Path=/; %sSameSite=Lax; Max-Age=%d%s",
                 name, value,
                 http_only ? "HttpOnly; " : "",
                 max_age,
                 g_dev_insecure_cookie ? "" : "; Secure");
  }
  return n > 0 && (size_t)n < capacity;
}

static Seobeo_Request_Entry *auth_json_response(
    Dowa_Arena *p_arena,
    const char *status,
    const char *body)
{
  Seobeo_Request_Entry *resp = NULL;
  Dowa_HashMap_Push_Arena(resp, "status", (char *)status, p_arena);
  Dowa_HashMap_Push_Arena(
      resp, "content-type", "application/json; charset=utf-8", p_arena);
  Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
  Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena);
  Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena);
  Dowa_HashMap_Push_Arena(resp, "body", (char *)body, p_arena);
  return resp;
}

static Seobeo_Request_Entry *auth_json_response_with_cookies(
    Dowa_Arena *p_arena,
    const char *status,
    const char *body,
    const char *cookie1,  /* value for "Set-Cookie"; NULL to skip */
    const char *cookie2)  /* value for "set-cookie"; NULL to skip */
{
  Seobeo_Request_Entry *resp = auth_json_response(p_arena, status, body);
  if (cookie1)
    Dowa_HashMap_Push_Arena(resp, "Set-Cookie", (char *)cookie1, p_arena);
  if (cookie2)
    Dowa_HashMap_Push_Arena(resp, "set-cookie", (char *)cookie2, p_arena);
  return resp;
}

static Seobeo_Request_Entry *auth_error(
    Dowa_Arena *p_arena,
    const char *status,
    const char *code,
    const char *message)
{
  char body[512];
  snprintf(body, sizeof(body),
           "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
           code, message);
  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body) + 1);
  if (body_copy) strcpy(body_copy, body);
  return auth_json_response(p_arena, status, body_copy ? body_copy : "{}");
}

/* ------------------------------------------------------------------ */
/* Principal resolution                                                 */
/* ------------------------------------------------------------------ */

boolean Auth_API_Resolve_Principal(
    Seobeo_Request_Entry *p_request,
    Auth_Principal       *p_principal,
    Dowa_Arena           *p_arena,
    char                 *new_guest_cookie_out,
    size_t                new_guest_cookie_capacity)
{
  if (!g_auth_store || !p_principal) return FALSE;

  memset(p_principal, 0, sizeof(*p_principal));
  if (new_guest_cookie_out && new_guest_cookie_capacity > 0)
    new_guest_cookie_out[0] = '\0';

  const char *cookie_header = auth_req_value(p_request, "Cookie");
  int64 now = auth_now();

  /* --- Try authenticated session first --- */
  char session_token[COOKIE_VALUE_MAX] = {0};
  if (cookie_header &&
      auth_parse_cookie(cookie_header, AUTH_API_SESSION_COOKIE_NAME,
                        session_token, sizeof(session_token)) &&
      session_token[0] != '\0')
  {
    char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
    if (Auth_Crypto_Token_Digest(session_token, token_digest,
                                 sizeof(token_digest)) == AUTH_CRYPTO_OK)
    {
      Auth_Session_Record session;
      Auth_User_Record    user;
      Auth_Store_Result result = Auth_Store_Find_Session(
          g_auth_store, token_digest, now, &session, &user);

      if (result == AUTH_STORE_OK)
      {
        Auth_Store_Touch_Session(
            g_auth_store, token_digest, now, g_session_idle_ttl);

        p_principal->kind = AUTH_PRINCIPAL_USER;
        strncpy(p_principal->user_id, user.id, sizeof(p_principal->user_id) - 1);
        strncpy(p_principal->username, user.username, sizeof(p_principal->username) - 1);
        strncpy(p_principal->role, user.role, sizeof(p_principal->role) - 1);
        p_principal->must_change_password = user.must_change_password;
        strncpy(p_principal->_binding, token_digest, sizeof(p_principal->_binding) - 1);
        if (!auth_derive_csrf(token_digest, p_principal->csrf_token,
                              sizeof(p_principal->csrf_token)))
        {
          OPENSSL_cleanse(session_token, sizeof(session_token));
          OPENSSL_cleanse(token_digest, sizeof(token_digest));
          memset(p_principal, 0, sizeof(*p_principal));
          return FALSE;
        }
        OPENSSL_cleanse(session_token, sizeof(session_token));
        OPENSSL_cleanse(token_digest, sizeof(token_digest));
        return TRUE;
      }
    }
    OPENSSL_cleanse(session_token, sizeof(session_token));
  }

  /* --- Try guest cookie --- */
  char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
  boolean have_ip = auth_peer_ip(p_request, peer_ip, sizeof(peer_ip));

  char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0};
  if (have_ip &&
      Auth_Crypto_IP_Binding_Digest(
          g_cookie_secret, g_cookie_secret_length,
          peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
    return FALSE;
  }

  char guest_cookie_val[COOKIE_VALUE_MAX] = {0};
  boolean guest_valid = FALSE;
  Auth_Crypto_Guest_Cookie guest_parsed;
  memset(&guest_parsed, 0, sizeof(guest_parsed));

  if (cookie_header &&
      auth_parse_cookie(cookie_header, AUTH_API_GUEST_COOKIE_NAME,
                        guest_cookie_val, sizeof(guest_cookie_val)) &&
      guest_cookie_val[0] != '\0' &&
      have_ip)
  {
    Auth_Crypto_Result cr = Auth_Crypto_Guest_Cookie_Verify(
        g_cookie_secret, g_cookie_secret_length,
        guest_cookie_val, (uint64)now,
        ip_binding, &guest_parsed);
    guest_valid = (cr == AUTH_CRYPTO_OK);
  }

  if (guest_valid)
  {
    Auth_Guest_Identity_Record identity;
    Auth_Store_Result result = Auth_Store_Find_Guest_Identity(
        g_auth_store, guest_parsed.guest_uuid, now, &identity);

    if (result == AUTH_STORE_OK)
    {
      Auth_Store_Upsert_Guest_Identity(
          g_auth_store, guest_parsed.guest_uuid,
          ip_binding, now + g_guest_ttl, &identity);

      p_principal->kind = AUTH_PRINCIPAL_GUEST;
      strncpy(p_principal->guest_id, guest_parsed.guest_uuid,
              sizeof(p_principal->guest_id) - 1);
      strncpy(p_principal->_binding, guest_parsed.guest_uuid,
              sizeof(p_principal->_binding) - 1);
      if (!auth_derive_csrf(guest_parsed.guest_uuid, p_principal->csrf_token,
                            sizeof(p_principal->csrf_token)))
      {
        OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
        OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
        memset(p_principal, 0, sizeof(*p_principal));
        return FALSE;
      }
      OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
      OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
      return TRUE;
    }
  }
  OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));

  /* --- Create new guest identity --- */
  char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE];
  if (!auth_uuid4(guest_uuid, sizeof(guest_uuid)))
  {
    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
    return FALSE;
  }

  int64 guest_expires = now + g_guest_ttl;
  Auth_Guest_Identity_Record new_identity;
  if (Auth_Store_Upsert_Guest_Identity(
          g_auth_store, guest_uuid, ip_binding, guest_expires,
          &new_identity) != AUTH_STORE_OK)
  {
    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
    return FALSE;
  }

  p_principal->kind = AUTH_PRINCIPAL_GUEST;
  strncpy(p_principal->guest_id, guest_uuid,
          sizeof(p_principal->guest_id) - 1);
  strncpy(p_principal->_binding, guest_uuid,
          sizeof(p_principal->_binding) - 1);
  if (!auth_derive_csrf(guest_uuid, p_principal->csrf_token,
                        sizeof(p_principal->csrf_token)))
  {
    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
    memset(p_principal, 0, sizeof(*p_principal));
    return FALSE;
  }

  /* Build new guest cookie for the response */
  if (new_guest_cookie_out && new_guest_cookie_capacity > 0 && have_ip)
  {
    char signed_cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE];
    if (Auth_Crypto_Guest_Cookie_Create(
            g_cookie_secret, g_cookie_secret_length,
            guest_uuid, (uint64)guest_expires, ip_binding,
            signed_cookie, sizeof(signed_cookie)) == AUTH_CRYPTO_OK)
    {
      auth_build_cookie_directive(
          AUTH_API_GUEST_COOKIE_NAME, signed_cookie,
          (int32)g_guest_ttl, TRUE,
          new_guest_cookie_out, new_guest_cookie_capacity);
      OPENSSL_cleanse(signed_cookie, sizeof(signed_cookie));
    }
  }

  OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
  return TRUE;
}

boolean Auth_API_Resolve_Existing_Principal(
    Seobeo_Request_Entry *p_request,
    Auth_Principal       *p_principal,
    Dowa_Arena           *p_arena,
    boolean              *p_found)
{
  if (!g_auth_store || !p_principal || !p_found) return FALSE;

  memset(p_principal, 0, sizeof(*p_principal));
  *p_found = FALSE;

  const char *cookie_header = auth_req_value(p_request, "Cookie");
  int64 now = auth_now();

  /* --- Try authenticated session first --- */
  char session_token[COOKIE_VALUE_MAX] = {0};
  if (cookie_header &&
      auth_parse_cookie(cookie_header, AUTH_API_SESSION_COOKIE_NAME,
                        session_token, sizeof(session_token)) &&
      session_token[0] != '\0')
  {
    char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
    if (Auth_Crypto_Token_Digest(session_token, token_digest,
                                 sizeof(token_digest)) == AUTH_CRYPTO_OK)
    {
      Auth_Session_Record session;
      Auth_User_Record    user;
      Auth_Store_Result result = Auth_Store_Find_Session(
          g_auth_store, token_digest, now, &session, &user);

      if (result == AUTH_STORE_OK)
      {
        Auth_Store_Touch_Session(
            g_auth_store, token_digest, now, g_session_idle_ttl);

        p_principal->kind = AUTH_PRINCIPAL_USER;
        strncpy(p_principal->user_id, user.id, sizeof(p_principal->user_id) - 1);
        strncpy(p_principal->username, user.username, sizeof(p_principal->username) - 1);
        strncpy(p_principal->role, user.role, sizeof(p_principal->role) - 1);
        p_principal->must_change_password = user.must_change_password;
        strncpy(p_principal->_binding, token_digest, sizeof(p_principal->_binding) - 1);
        if (!auth_derive_csrf(token_digest, p_principal->csrf_token,
                              sizeof(p_principal->csrf_token)))
        {
          OPENSSL_cleanse(session_token, sizeof(session_token));
          OPENSSL_cleanse(token_digest, sizeof(token_digest));
          memset(p_principal, 0, sizeof(*p_principal));
          return FALSE;
        }
        OPENSSL_cleanse(session_token, sizeof(session_token));
        OPENSSL_cleanse(token_digest, sizeof(token_digest));
        *p_found = TRUE;
        return TRUE;
      }
    }
    OPENSSL_cleanse(session_token, sizeof(session_token));
  }

  /* --- Try existing guest cookie (no new guest created) --- */
  char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
  boolean have_ip = auth_peer_ip(p_request, peer_ip, sizeof(peer_ip));

  char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0};
  if (have_ip &&
      Auth_Crypto_IP_Binding_Digest(
          g_cookie_secret, g_cookie_secret_length,
          peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
    return FALSE;
  }

  char guest_cookie_val[COOKIE_VALUE_MAX] = {0};
  Auth_Crypto_Guest_Cookie guest_parsed;
  memset(&guest_parsed, 0, sizeof(guest_parsed));

  if (cookie_header &&
      auth_parse_cookie(cookie_header, AUTH_API_GUEST_COOKIE_NAME,
                        guest_cookie_val, sizeof(guest_cookie_val)) &&
      guest_cookie_val[0] != '\0' &&
      have_ip)
  {
    Auth_Crypto_Result cr = Auth_Crypto_Guest_Cookie_Verify(
        g_cookie_secret, g_cookie_secret_length,
        guest_cookie_val, (uint64)now,
        ip_binding, &guest_parsed);
    if (cr == AUTH_CRYPTO_OK)
    {
      Auth_Guest_Identity_Record identity;
      Auth_Store_Result result = Auth_Store_Find_Guest_Identity(
          g_auth_store, guest_parsed.guest_uuid, now, &identity);

      if (result == AUTH_STORE_OK)
      {
        Auth_Store_Upsert_Guest_Identity(
            g_auth_store, guest_parsed.guest_uuid,
            ip_binding, now + g_guest_ttl, &identity);

        p_principal->kind = AUTH_PRINCIPAL_GUEST;
        strncpy(p_principal->guest_id, guest_parsed.guest_uuid,
                sizeof(p_principal->guest_id) - 1);
        strncpy(p_principal->_binding, guest_parsed.guest_uuid,
                sizeof(p_principal->_binding) - 1);
        if (!auth_derive_csrf(guest_parsed.guest_uuid, p_principal->csrf_token,
                              sizeof(p_principal->csrf_token)))
        {
          OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
          OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
          memset(p_principal, 0, sizeof(*p_principal));
          return FALSE;
        }
        OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
        OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
        *p_found = TRUE;
        return TRUE;
      }
    }
  }
  OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
  OPENSSL_cleanse(peer_ip, sizeof(peer_ip));

  /* No existing identity found — caller should return 401.
   * We do NOT create a guest row or generate a Set-Cookie directive. */
  *p_found = FALSE;
  return TRUE;
}

/* ------------------------------------------------------------------ */
/* CSRF verification helper                                             */
/* ------------------------------------------------------------------ */

/*
 * Verify a CSRF token provided by the client against the binding for
 * the current session/guest.  Comparison is by SHA-256 digest equality
 * to avoid timing-oracle attacks on the base64url token directly.
 */
static boolean auth_verify_csrf(
    const char *provided_token,
    const char *binding)
{
  if (!provided_token || !binding || provided_token[0] == '\0')
    return FALSE;

  char expected[AUTH_CRYPTO_TOKEN_SIZE];
  if (!auth_derive_csrf(binding, expected, sizeof(expected)))
    return FALSE;

  /* Compare SHA-256 digests of both tokens (constant-time length comparison) */
  char digest_provided[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
  char digest_expected[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];

  if (Auth_Crypto_Token_Digest(provided_token, digest_provided,
                                sizeof(digest_provided)) != AUTH_CRYPTO_OK ||
      Auth_Crypto_Token_Digest(expected, digest_expected,
                                sizeof(digest_expected)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(expected, sizeof(expected));
    OPENSSL_cleanse(digest_provided, sizeof(digest_provided));
    OPENSSL_cleanse(digest_expected, sizeof(digest_expected));
    return FALSE;
  }

  int match = CRYPTO_memcmp(digest_provided, digest_expected,
                             sizeof(digest_provided));
  OPENSSL_cleanse(expected, sizeof(expected));
  OPENSSL_cleanse(digest_provided, sizeof(digest_provided));
  OPENSSL_cleanse(digest_expected, sizeof(digest_expected));
  return match == 0;
}

/* ------------------------------------------------------------------ */
/* Public: Auth_API_Verify_CSRF                                         */
/* ------------------------------------------------------------------ */

boolean Auth_API_Verify_CSRF(
    Seobeo_Request_Entry *p_request,
    const Auth_Principal *p_principal)
{
  if (!p_request || !p_principal)
    return FALSE;
  if (!auth_same_origin(p_request))
    return FALSE;
  const char *csrf = auth_req_value(p_request, "X-CSRF-Token");
  if (!csrf || csrf[0] == '\0')
    return FALSE;
  return auth_verify_csrf(csrf, p_principal->_binding);
}

/* ------------------------------------------------------------------ */
/* Route: GET /api/auth/session                                         */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *auth_session_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  if (!g_auth_store)
    return auth_error(p_arena, "503", "service_unavailable",
                      "Auth not initialised");

  Auth_Principal principal;
  char new_guest_cookie[GUEST_COOKIE_MAX] = {0};

  if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena,
                                   new_guest_cookie, sizeof(new_guest_cookie)))
    return auth_error(p_arena, "500", "internal_error", "Session error");

  char body[2048];
  if (principal.kind == AUTH_PRINCIPAL_USER)
  {
    char *safe_username =
        Dowa_JSON_Escape_String(principal.username, 0, p_arena);
    char *safe_role =
        Dowa_JSON_Escape_String(principal.role, 0, p_arena);
    char *safe_csrf =
        Dowa_JSON_Escape_String(principal.csrf_token, 0, p_arena);
    if (!safe_username || !safe_role || !safe_csrf)
      return auth_error(p_arena, "500", "internal_error", "Encode error");

    snprintf(body, sizeof(body),
             "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\","
             "\"mustChangePassword\":%s,\"csrfToken\":\"%s\","
             "\"quota\":null}",
             safe_username, safe_role,
             principal.must_change_password ? "true" : "false",
             safe_csrf);
  }
  else
  {
    char *safe_csrf =
        Dowa_JSON_Escape_String(principal.csrf_token, 0, p_arena);
    if (!safe_csrf)
      return auth_error(p_arena, "500", "internal_error", "Encode error");

    /* Ask conversation layer for quota JSON (null if not registered). */
    char quota_json[512] = "null";
    if (g_guest_quota_cb)
      g_guest_quota_cb(principal.guest_id, auth_now(), quota_json,
                       sizeof(quota_json));

    snprintf(body, sizeof(body),
             "{\"kind\":\"guest\",\"csrfToken\":\"%s\",\"quota\":%s}",
             safe_csrf, quota_json);
  }

  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body) + 1);
  if (!body_copy)
    return auth_error(p_arena, "500", "internal_error", "OOM");
  strcpy(body_copy, body);

  const char *cookie1 = (new_guest_cookie[0] != '\0') ? new_guest_cookie : NULL;
  return auth_json_response_with_cookies(
      p_arena, "200", body_copy, cookie1, NULL);
}

/* ------------------------------------------------------------------ */
/* Route: POST /api/auth/login                                          */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *auth_login_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  if (!g_auth_store)
    return auth_error(p_arena, "503", "service_unavailable",
                      "Auth not initialised");

  if (!auth_same_origin(p_req))
    return auth_error(p_arena, "403", "forbidden", "Origin mismatch");

  /* --- Parse body --- */
  const char *body_str = auth_req_value(p_req, "Body");
  if (!body_str)
    return auth_error(p_arena, "400", "bad_request", "Invalid body");

  size_t body_len = strlen(body_str);
  if (body_len > BODY_MAX_BYTES)
  {
    OPENSSL_cleanse((char *)body_str, body_len);
    return auth_error(p_arena, "400", "bad_request", "Invalid body");
  }
  Dowa_JSON_Value jv =
      Dowa_JSON_Parse(body_str, (int32)body_len, p_arena);
  OPENSSL_cleanse((char *)body_str, body_len);
  if (jv.type != DOWA_JSON_OBJECT)
    return auth_error(p_arena, "400", "bad_request", "Expected JSON object");

  Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
  char *username_raw  = Dowa_JSON_Get_String(obj, "username");
  char *csrf_provided = Dowa_JSON_Get_String(obj, "csrfToken");
  char password_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
  memset(password_buf, 0, sizeof(password_buf));
  boolean have_password = auth_extract_secret_field(
      obj, "password", password_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES);
  char *password_raw = password_buf;

  if (!username_raw || !have_password || !csrf_provided ||
      username_raw[0] == '\0' ||
      csrf_provided[0] == '\0')
  {
    OPENSSL_cleanse(password_buf, sizeof(password_buf));
    return auth_error(p_arena, "400", "bad_request", "Missing fields");
  }

  /* --- Password length bounds --- */
  size_t pw_len = strlen(password_raw);
  if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
  {
    OPENSSL_cleanse(password_raw, pw_len);
    return auth_error(p_arena, "401", "invalid_credentials",
                      "Invalid credentials");
  }

  /* --- Resolve existing principal for CSRF binding --- */
  Auth_Principal principal;
  char ignored_cookie[GUEST_COOKIE_MAX] = {0};
  if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena,
                                   ignored_cookie, sizeof(ignored_cookie)))
  {
    OPENSSL_cleanse(password_raw, pw_len);
    return auth_error(p_arena, "500", "internal_error", "Session error");
  }

  /* --- CSRF check --- */
  if (!auth_verify_csrf(csrf_provided, principal._binding))
  {
    OPENSSL_cleanse(password_raw, pw_len);
    return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid");
  }

  /* --- Peer IP and rate-limit key --- */
  char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
  boolean have_ip = auth_peer_ip(p_req, peer_ip, sizeof(peer_ip));

  char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0};
  if (have_ip &&
      Auth_Crypto_IP_Binding_Digest(
          g_cookie_secret, g_cookie_secret_length,
          peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
    OPENSSL_cleanse(password_raw, pw_len);
    return auth_error(p_arena, "500", "internal_error", "Binding error");
  }
  OPENSSL_cleanse(peer_ip, sizeof(peer_ip));

  /* Normalize username */
  char norm_username[AUTH_STORE_USERNAME_MAX + 1] = {0};
  if (!Auth_Store_Normalize_Username(
          username_raw, norm_username, sizeof(norm_username)))
  {
    OPENSSL_cleanse(password_raw, pw_len);
    return auth_error(p_arena, "401", "invalid_credentials",
                      "Invalid credentials");
  }

  /* Rate limit check */
  char rate_key[RATE_KEY_MAX] = {0};
  boolean have_rate_key =
      auth_rate_key(ip_binding, norm_username, rate_key);

  if (have_rate_key && auth_rate_check(rate_key))
  {
    OPENSSL_cleanse(password_raw, pw_len);
    OPENSSL_cleanse(norm_username, sizeof(norm_username));
    return auth_error(p_arena, "429", "too_many_requests",
                      "Too many login attempts");
  }

  /* --- Fetch user record --- */
  Auth_User_Auth_Record auth_record;
  memset(&auth_record, 0, sizeof(auth_record));
  Auth_Store_Result find_result =
      Auth_Store_Find_User_By_Username(
          g_auth_store, norm_username, &auth_record);

  if (find_result != AUTH_STORE_OK)
  {
    /*
     * User not found; run exactly one scrypt verification against a fixed
     * precomputed hash to consume constant time, then return a generic error.
     * AUTH_DUMMY_PASSWORD_HASH is a valid zenbu-scrypt hash of a known
     * constant string — it is never an account credential.
     */
    Auth_Crypto_Password_Verify(password_raw, AUTH_DUMMY_PASSWORD_HASH);
    OPENSSL_cleanse(password_raw, pw_len);
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    if (have_rate_key)
      auth_rate_record_failure(rate_key);
    return auth_error(p_arena, "401", "invalid_credentials",
                      "Invalid credentials");
  }

  if (strcmp(auth_record.user.status, "active") != 0)
  {
    Auth_Crypto_Password_Verify(password_raw, auth_record.password_hash);
    OPENSSL_cleanse(password_raw, pw_len);
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    if (have_rate_key)
      auth_rate_record_failure(rate_key);
    return auth_error(p_arena, "401", "invalid_credentials",
                      "Invalid credentials");
  }

  /* --- Verify password --- */
  Auth_Crypto_Result verify =
      Auth_Crypto_Password_Verify(password_raw, auth_record.password_hash);
  OPENSSL_cleanse(password_raw, pw_len);

  if (verify != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    if (have_rate_key)
      auth_rate_record_failure(rate_key);
    return auth_error(p_arena, "401", "invalid_credentials",
                      "Invalid credentials");
  }

  /* Successful authentication: reset rate limit */
  if (have_rate_key)
    auth_rate_reset(rate_key);

  /* --- Create new session (prevents session fixation) --- */
  char new_token[AUTH_CRYPTO_TOKEN_SIZE];
  if (Auth_Crypto_Token_Generate(new_token, sizeof(new_token)) !=
      AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    return auth_error(p_arena, "500", "internal_error", "Token error");
  }

  char new_token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
  if (Auth_Crypto_Token_Digest(new_token, new_token_digest,
                                sizeof(new_token_digest)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_token, sizeof(new_token));
    return auth_error(p_arena, "500", "internal_error", "Digest error");
  }

  /* CSRF for this new session */
  char new_csrf[AUTH_CRYPTO_TOKEN_SIZE];
  if (!auth_derive_csrf(new_token_digest, new_csrf, sizeof(new_csrf)))
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_token, sizeof(new_token));
    OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
    return auth_error(p_arena, "500", "internal_error", "CSRF error");
  }

  char csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
  if (Auth_Crypto_Token_Digest(new_csrf, csrf_digest,
                                sizeof(csrf_digest)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_token, sizeof(new_token));
    OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
    OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
    return auth_error(p_arena, "500", "internal_error", "CSRF digest error");
  }

  int64 now = auth_now();
  Auth_Session_Record session;
#ifdef AUTH_API_TEST_HOOKS
  if (g_login_pre_create_hook)
    g_login_pre_create_hook(g_login_pre_create_context);
#endif
  Auth_Store_Result create_result = Auth_Store_Create_Session_CAS(
      g_auth_store,
      auth_record.user.id,
      auth_record.password_hash,
      new_token_digest,
      csrf_digest,
      g_session_idle_ttl,
      g_session_abs_ttl,
      now,
      &session);

  OPENSSL_cleanse(auth_record.password_hash,
                  sizeof(auth_record.password_hash));
  OPENSSL_cleanse(csrf_digest, sizeof(csrf_digest));
  OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));

  if (create_result != AUTH_STORE_OK)
  {
    OPENSSL_cleanse(new_token, sizeof(new_token));
    OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
    if (create_result == AUTH_STORE_STALE_PASSWORD)
      return auth_error(p_arena, "401", "invalid_credentials",
                        "Invalid credentials");
    return auth_error(p_arena, "500", "internal_error", "Session create failed");
  }

  /* --- Call guest transfer hook (before clearing guest state) --- */
  if (g_transfer_hook &&
      principal.kind == AUTH_PRINCIPAL_GUEST &&
      principal.guest_id[0] != '\0')
  {
    boolean transferred = g_transfer_hook(
        principal.guest_id, auth_record.user.id, g_transfer_hook_ctx);
    if (!transferred)
    {
      /* Transfer failed — revoke the new session and return 500.
       * Guest cookie/data are preserved (no clear_guest in response). */
      char rev_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
      if (Auth_Crypto_Token_Digest(new_token, rev_digest,
                                   sizeof(rev_digest)) == AUTH_CRYPTO_OK)
      {
        Auth_Store_Result revoke_result =
            Auth_Store_Revoke_Session(g_auth_store, rev_digest);
        if (revoke_result == AUTH_STORE_ERROR)
          Seobeo_Log(SEOBEO_ERROR,
                     "[AUTH] Failed to revoke session after transfer failure\n");
        OPENSSL_cleanse(rev_digest, sizeof(rev_digest));
      }
      OPENSSL_cleanse(new_token, sizeof(new_token));
      OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
      return auth_error(p_arena, "500", "transfer_failed",
                        "Resource transfer failed");
    }
  }

  /* --- Build response --- */
  char *safe_username =
      Dowa_JSON_Escape_String(auth_record.user.username, 0, p_arena);
  char *safe_role =
      Dowa_JSON_Escape_String(auth_record.user.role, 0, p_arena);
  char *safe_csrf =
      Dowa_JSON_Escape_String(new_csrf, 0, p_arena);
  OPENSSL_cleanse(new_csrf, sizeof(new_csrf));

  if (!safe_username || !safe_role || !safe_csrf)
  {
    OPENSSL_cleanse(new_token, sizeof(new_token));
    return auth_error(p_arena, "500", "internal_error", "Encode error");
  }

  char body_buf[512];
  snprintf(body_buf, sizeof(body_buf),
           "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\","
           "\"mustChangePassword\":%s,\"csrfToken\":\"%s\","
           "\"quota\":null}",
           safe_username, safe_role,
           auth_record.user.must_change_password ? "true" : "false",
           safe_csrf);

  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
  if (!body_copy)
  {
    OPENSSL_cleanse(new_token, sizeof(new_token));
    return auth_error(p_arena, "500", "internal_error", "OOM");
  }
  strcpy(body_copy, body_buf);

  /* Session cookie */
  char session_cookie[SESSION_COOKIE_MAX];
  auth_build_cookie_directive(
      AUTH_API_SESSION_COOKIE_NAME, new_token, 0,
      TRUE, session_cookie, sizeof(session_cookie));
  OPENSSL_cleanse(new_token, sizeof(new_token));

  char *session_cookie_copy =
      Dowa_Arena_Allocate(p_arena, strlen(session_cookie) + 1);
  if (session_cookie_copy) strcpy(session_cookie_copy, session_cookie);
  OPENSSL_cleanse(session_cookie, sizeof(session_cookie));

  /* Clear guest cookie */
  char clear_guest[256];
  auth_build_cookie_directive(
      AUTH_API_GUEST_COOKIE_NAME, "", -1,
      TRUE, clear_guest, sizeof(clear_guest));

  char *clear_guest_copy =
      Dowa_Arena_Allocate(p_arena, strlen(clear_guest) + 1);
  if (clear_guest_copy) strcpy(clear_guest_copy, clear_guest);

  return auth_json_response_with_cookies(
      p_arena, "200", body_copy,
      session_cookie_copy, clear_guest_copy);
}

/* ------------------------------------------------------------------ */
/* Route: POST /api/auth/logout                                         */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *auth_logout_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  if (!g_auth_store)
    return auth_error(p_arena, "503", "service_unavailable",
                      "Auth not initialised");

  if (!auth_same_origin(p_req))
    return auth_error(p_arena, "403", "forbidden", "Origin mismatch");

  /* --- Resolve current principal for CSRF binding --- */
  Auth_Principal principal;
  char ignored_cookie[GUEST_COOKIE_MAX] = {0};
  if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena,
                                   ignored_cookie, sizeof(ignored_cookie)))
    return auth_error(p_arena, "500", "internal_error", "Session error");

  /* --- CSRF check --- */
  const char *body_str = auth_req_value(p_req, "Body");
  char csrf_provided[AUTH_CRYPTO_TOKEN_SIZE] = {0};
  if (body_str && strlen(body_str) <= BODY_MAX_BYTES)
  {
    Dowa_JSON_Value jv =
        Dowa_JSON_Parse(body_str, (int32)strlen(body_str), p_arena);
    if (jv.type == DOWA_JSON_OBJECT)
    {
      char *t = Dowa_JSON_Get_String((Dowa_JSON_Entry *)jv.object_val,
                                     "csrfToken");
      if (t)
        strncpy(csrf_provided, t,
                sizeof(csrf_provided) - 1);
    }
  }

  /* Also accept CSRF from X-CSRF-Token header */
  if (csrf_provided[0] == '\0')
  {
    const char *hdr = auth_req_value(p_req, "X-CSRF-Token");
    if (hdr)
      strncpy(csrf_provided, hdr, sizeof(csrf_provided) - 1);
  }

  if (!auth_verify_csrf(csrf_provided, principal._binding))
    return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid");

  /* --- Revoke session if authenticated --- */
  const char *cookie_hdr = auth_req_value(p_req, "Cookie");
  char session_token[COOKIE_VALUE_MAX] = {0};
  if (cookie_hdr &&
      auth_parse_cookie(cookie_hdr, AUTH_API_SESSION_COOKIE_NAME,
                        session_token, sizeof(session_token)) &&
      session_token[0] != '\0')
  {
    char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE] = {0};
    Auth_Crypto_Result digest_result =
        Auth_Crypto_Token_Digest(
            session_token, token_digest, sizeof(token_digest));
    if (digest_result == AUTH_CRYPTO_OK)
    {
      Auth_Store_Result revoke_result =
          Auth_Store_Revoke_Session(g_auth_store, token_digest);
      OPENSSL_cleanse(token_digest, sizeof(token_digest));
      if (revoke_result == AUTH_STORE_ERROR)
      {
        OPENSSL_cleanse(session_token, sizeof(session_token));
        return auth_error(p_arena, "500", "internal_error", "Logout failed");
      }
    }
    else
    {
      OPENSSL_cleanse(token_digest, sizeof(token_digest));
      OPENSSL_cleanse(session_token, sizeof(session_token));
      return auth_error(p_arena, "500", "internal_error", "Logout failed");
    }
    OPENSSL_cleanse(session_token, sizeof(session_token));
  }

  /* --- Create fresh guest identity --- */
  char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
  boolean have_ip = auth_peer_ip(p_req, peer_ip, sizeof(peer_ip));

  char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0};
  if (have_ip &&
      Auth_Crypto_IP_Binding_Digest(
          g_cookie_secret, g_cookie_secret_length,
          peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
    return auth_error(p_arena, "500", "internal_error", "Binding error");
  }
  OPENSSL_cleanse(peer_ip, sizeof(peer_ip));

  char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE] = {0};
  if (!auth_uuid4(guest_uuid, sizeof(guest_uuid)))
    return auth_error(p_arena, "500", "internal_error", "UUID error");

  int64 now = auth_now();
  int64 guest_expires = now + g_guest_ttl;
  Auth_Guest_Identity_Record new_identity;
  Auth_Store_Upsert_Guest_Identity(
      g_auth_store, guest_uuid, ip_binding, guest_expires, &new_identity);

  char new_csrf[AUTH_CRYPTO_TOKEN_SIZE] = {0};
  if (!auth_derive_csrf(guest_uuid, new_csrf, sizeof(new_csrf)))
    return auth_error(p_arena, "500", "internal_error", "CSRF error");

  char signed_cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
  char new_guest_cookie[GUEST_COOKIE_MAX] = {0};
  if (have_ip &&
      Auth_Crypto_Guest_Cookie_Create(
          g_cookie_secret, g_cookie_secret_length,
          guest_uuid, (uint64)guest_expires, ip_binding,
          signed_cookie, sizeof(signed_cookie)) == AUTH_CRYPTO_OK)
  {
    auth_build_cookie_directive(
        AUTH_API_GUEST_COOKIE_NAME, signed_cookie,
        (int32)g_guest_ttl, TRUE,
        new_guest_cookie, sizeof(new_guest_cookie));
    OPENSSL_cleanse(signed_cookie, sizeof(signed_cookie));
  }

  /* --- Build response --- */
  char *safe_csrf = Dowa_JSON_Escape_String(new_csrf, 0, p_arena);
  OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
  if (!safe_csrf)
    return auth_error(p_arena, "500", "internal_error", "Encode error");

  char body_buf[256];
  snprintf(body_buf, sizeof(body_buf),
           "{\"kind\":\"guest\",\"csrfToken\":\"%s\",\"quota\":null}",
           safe_csrf);

  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
  if (!body_copy)
    return auth_error(p_arena, "500", "internal_error", "OOM");
  strcpy(body_copy, body_buf);

  /* Clear session cookie */
  char clear_session[256];
  auth_build_cookie_directive(
      AUTH_API_SESSION_COOKIE_NAME, "", -1,
      TRUE, clear_session, sizeof(clear_session));

  char *clear_session_copy =
      Dowa_Arena_Allocate(p_arena, strlen(clear_session) + 1);
  if (!clear_session_copy)
    return auth_error(p_arena, "500", "internal_error", "OOM");
  strcpy(clear_session_copy, clear_session);

  char *new_guest_copy = NULL;
  if (new_guest_cookie[0] != '\0')
  {
    new_guest_copy =
        Dowa_Arena_Allocate(p_arena, strlen(new_guest_cookie) + 1);
    if (!new_guest_copy)
      return auth_error(p_arena, "500", "internal_error", "OOM");
    strcpy(new_guest_copy, new_guest_cookie);
  }

  return auth_json_response_with_cookies(
      p_arena, "200", body_copy,
      clear_session_copy,
      new_guest_copy);
}

/* ------------------------------------------------------------------ */
/* Route: POST /api/auth/password                                       */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *auth_password_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  if (!g_auth_store)
    return auth_error(p_arena, "503", "service_unavailable",
                      "Auth not initialised");

  if (!auth_same_origin(p_req))
    return auth_error(p_arena, "403", "forbidden", "Origin mismatch");

  /* --- Resolve principal — must be authenticated user --- */
  Auth_Principal principal;
  char ignored_cookie[GUEST_COOKIE_MAX] = {0};
  if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena,
                                   ignored_cookie, sizeof(ignored_cookie)))
    return auth_error(p_arena, "500", "internal_error", "Session error");

  if (principal.kind != AUTH_PRINCIPAL_USER)
    return auth_error(p_arena, "401", "unauthenticated",
                      "Authentication required");

  /* --- Parse body --- */
  const char *body_str = auth_req_value(p_req, "Body");
  if (!body_str)
    return auth_error(p_arena, "400", "bad_request", "Invalid body");

  size_t body_len = strlen(body_str);
  if (body_len > BODY_MAX_BYTES)
  {
    OPENSSL_cleanse((char *)body_str, body_len);
    return auth_error(p_arena, "400", "bad_request", "Invalid body");
  }
  Dowa_JSON_Value jv =
      Dowa_JSON_Parse(body_str, (int32)body_len, p_arena);
  OPENSSL_cleanse((char *)body_str, body_len);
  if (jv.type != DOWA_JSON_OBJECT)
    return auth_error(p_arena, "400", "bad_request", "Expected JSON object");

  Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
  char *csrf_provided = Dowa_JSON_Get_String(obj, "csrfToken");
  char current_pw_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
  char new_pw_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
  memset(current_pw_buf, 0, sizeof(current_pw_buf));
  memset(new_pw_buf, 0, sizeof(new_pw_buf));
  boolean have_current = auth_extract_secret_field(
      obj, "currentPassword", current_pw_buf,
      AUTH_CRYPTO_PASSWORD_MAX_BYTES);
  boolean have_new = auth_extract_secret_field(
      obj, "newPassword", new_pw_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES);
  char *current_pw_raw = current_pw_buf;
  char *new_pw_raw = new_pw_buf;

  /* Also accept CSRF from header */
  if (!csrf_provided || csrf_provided[0] == '\0')
    csrf_provided = (char *)auth_req_value(p_req, "X-CSRF-Token");

  if (!have_current || !have_new || !csrf_provided)
  {
    OPENSSL_cleanse(current_pw_buf, sizeof(current_pw_buf));
    OPENSSL_cleanse(new_pw_buf, sizeof(new_pw_buf));
    return auth_error(p_arena, "400", "bad_request", "Missing fields");
  }

  /* --- CSRF check --- */
  if (!auth_verify_csrf(csrf_provided, principal._binding))
  {
    OPENSSL_cleanse(current_pw_buf, sizeof(current_pw_buf));
    OPENSSL_cleanse(new_pw_buf, sizeof(new_pw_buf));
    return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid");
  }

  /* --- Password policy --- */
  size_t new_pw_len = strlen(new_pw_raw);
  size_t cur_pw_len = strlen(current_pw_raw);
  if (new_pw_len < 12 || new_pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES ||
      cur_pw_len  < 1  || cur_pw_len  > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
  {
    OPENSSL_cleanse(current_pw_raw, cur_pw_len);
    OPENSSL_cleanse(new_pw_raw, new_pw_len);
    return auth_error(p_arena, "400", "password_policy",
                      "New password must be at least 12 characters");
  }

  /* --- Fetch user with password hash --- */
  char norm_username[AUTH_STORE_USERNAME_MAX + 1];
  if (!Auth_Store_Normalize_Username(
          principal.username, norm_username, sizeof(norm_username)))
  {
    OPENSSL_cleanse(current_pw_raw, cur_pw_len);
    OPENSSL_cleanse(new_pw_raw, new_pw_len);
    return auth_error(p_arena, "500", "internal_error", "Username error");
  }

  Auth_User_Auth_Record auth_record;
  memset(&auth_record, 0, sizeof(auth_record));
  if (Auth_Store_Find_User_By_Username(
          g_auth_store, norm_username, &auth_record) != AUTH_STORE_OK)
  {
    OPENSSL_cleanse(current_pw_raw, cur_pw_len);
    OPENSSL_cleanse(new_pw_raw, new_pw_len);
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    return auth_error(p_arena, "401", "invalid_credentials",
                      "Invalid credentials");
  }

  /* --- Verify current password --- */
  Auth_Crypto_Result verify =
      Auth_Crypto_Password_Verify(current_pw_raw, auth_record.password_hash);
  OPENSSL_cleanse(current_pw_raw, cur_pw_len);

  if (verify != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_pw_raw, new_pw_len);
    return auth_error(p_arena, "401", "invalid_credentials",
                      "Invalid credentials");
  }

  /* --- Hash new password --- */
  char new_encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
  Auth_Crypto_Result hash_result = Auth_Crypto_Password_Hash(
      new_pw_raw, new_encoded_hash, sizeof(new_encoded_hash));
  OPENSSL_cleanse(new_pw_raw, new_pw_len);

  if (hash_result != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
    return auth_error(p_arena, "500", "internal_error", "Hash error");
  }

  /* Generate the replacement session before entering the transaction. */
  char new_token[AUTH_CRYPTO_TOKEN_SIZE];
  if (Auth_Crypto_Token_Generate(new_token, sizeof(new_token)) !=
      AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
    return auth_error(p_arena, "500", "internal_error", "Token error");
  }

  char new_token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
  if (Auth_Crypto_Token_Digest(new_token, new_token_digest,
                                sizeof(new_token_digest)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
    OPENSSL_cleanse(new_token, sizeof(new_token));
    return auth_error(p_arena, "500", "internal_error", "Digest error");
  }

  char new_csrf[AUTH_CRYPTO_TOKEN_SIZE];
  if (!auth_derive_csrf(new_token_digest, new_csrf, sizeof(new_csrf)))
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
    OPENSSL_cleanse(new_token, sizeof(new_token));
    OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
    return auth_error(p_arena, "500", "internal_error", "CSRF error");
  }

  char new_csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
  if (Auth_Crypto_Token_Digest(new_csrf, new_csrf_digest,
                                sizeof(new_csrf_digest)) != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(auth_record.password_hash,
                    sizeof(auth_record.password_hash));
    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
    OPENSSL_cleanse(new_token, sizeof(new_token));
    OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
    OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
    return auth_error(p_arena, "500", "internal_error", "CSRF digest error");
  }

  int64 now = auth_now();
  Auth_Session_Record new_session;
  Auth_Store_Result password_result = Auth_Store_Self_Change_Password(
      g_auth_store,
      principal.user_id,
      auth_record.password_hash,
      new_encoded_hash,
      new_token_digest,
      new_csrf_digest,
      g_session_idle_ttl,
      g_session_abs_ttl,
      now,
      &new_session);

  OPENSSL_cleanse(auth_record.password_hash,
                  sizeof(auth_record.password_hash));
  OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
  OPENSSL_cleanse(new_csrf_digest, sizeof(new_csrf_digest));
  OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));

  if (password_result != AUTH_STORE_OK)
  {
    OPENSSL_cleanse(new_token, sizeof(new_token));
    OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
    return auth_error(p_arena, "500", "internal_error",
                      "Password update failed");
  }

  /* --- Build response --- */
  char *safe_username =
      Dowa_JSON_Escape_String(principal.username, 0, p_arena);
  char *safe_role =
      Dowa_JSON_Escape_String(principal.role, 0, p_arena);
  char *safe_csrf =
      Dowa_JSON_Escape_String(new_csrf, 0, p_arena);
  OPENSSL_cleanse(new_csrf, sizeof(new_csrf));

  if (!safe_username || !safe_role || !safe_csrf)
  {
    OPENSSL_cleanse(new_token, sizeof(new_token));
    return auth_error(p_arena, "500", "internal_error", "Encode error");
  }

  char body_buf[512];
  snprintf(body_buf, sizeof(body_buf),
           "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\","
           "\"mustChangePassword\":false,\"csrfToken\":\"%s\","
           "\"quota\":null}",
           safe_username, safe_role, safe_csrf);

  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
  if (!body_copy)
  {
    OPENSSL_cleanse(new_token, sizeof(new_token));
    return auth_error(p_arena, "500", "internal_error", "OOM");
  }
  strcpy(body_copy, body_buf);

  /* Updated session cookie */
  char session_cookie[SESSION_COOKIE_MAX];
  auth_build_cookie_directive(
      AUTH_API_SESSION_COOKIE_NAME, new_token, 0,
      TRUE, session_cookie, sizeof(session_cookie));
  OPENSSL_cleanse(new_token, sizeof(new_token));

  char *session_cookie_copy =
      Dowa_Arena_Allocate(p_arena, strlen(session_cookie) + 1);
  if (session_cookie_copy) strcpy(session_cookie_copy, session_cookie);
  OPENSSL_cleanse(session_cookie, sizeof(session_cookie));

  return auth_json_response_with_cookies(
      p_arena, "200", body_copy,
      session_cookie_copy, NULL);
}

/* ------------------------------------------------------------------ */
/* Route: GET /account/password                                         */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *auth_password_page_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  (void)p_req;
  char *body = Dowa_Arena_Allocate(p_arena, 128 * 1024);
  if (!body || !Mjj_Template_Render_File(body, 128 * 1024, "/account/password.html", p_arena))
  {
    Seobeo_Request_Entry *resp = NULL;
    Dowa_HashMap_Push_Arena(resp, "status", "500", p_arena);
    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain; charset=utf-8", p_arena);
    Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
    Dowa_HashMap_Push_Arena(resp, "body", "Internal Server Error", p_arena);
    return resp;
  }

  Seobeo_Request_Entry *resp = NULL;
  Dowa_HashMap_Push_Arena(resp, "body", body, p_arena);
  Dowa_HashMap_Push_Arena(
      resp, "content-type", "text/html; charset=utf-8", p_arena);
  Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
  Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena);
  Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena);
  Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", p_arena);
  Dowa_HashMap_Push_Arena(
      resp, "content-security-policy", "frame-ancestors 'none'", p_arena);
  return resp;
}

/* ------------------------------------------------------------------ */
/* Public API                                                           */
/* ------------------------------------------------------------------ */

boolean Auth_API_Is_Forced_Password_Change_Only(const char *http_path)
{
  if (!http_path) return FALSE;
  return strcmp(http_path, AUTH_API_PATH_SESSION)       == 0 ||
         strcmp(http_path, AUTH_API_PATH_LOGIN)         == 0 ||
         strcmp(http_path, AUTH_API_PATH_LOGOUT)        == 0 ||
         strcmp(http_path, AUTH_API_PATH_PASSWORD)      == 0 ||
         strcmp(http_path, AUTH_API_PATH_PASSWORD_PAGE) == 0;
}

void Auth_API_Register_Guest_Transfer_Hook(
    Auth_Guest_Transfer_Hook hook,
    void                    *context)
{
  g_transfer_hook     = hook;
  g_transfer_hook_ctx = context;
}

void Auth_API_Register_Guest_Quota_Cb(Auth_API_Guest_Quota_Cb cb)
{
  g_guest_quota_cb = cb;
}

boolean Auth_API_Init(
    const char  *database_path,
    const uint8 *cookie_secret,
    size_t       cookie_secret_length,
    const char  *bootstrap_username,
    const char  *bootstrap_password_hash,
    const char  *trusted_proxy_ip,
    int64        session_idle_ttl_secs,
    int64        session_absolute_ttl_secs,
    int64        guest_ttl_secs,
    boolean      dev_insecure_cookie)
{
  /* Fail closed: cookie secret required */
  if (!cookie_secret ||
      cookie_secret_length < AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES ||
      cookie_secret_length > AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES)
  {
    Seobeo_Log(SEOBEO_ERROR,
               "[AUTH] Init failed: cookie secret missing or invalid length\n");
    return FALSE;
  }

  if (!database_path || database_path[0] == '\0')
  {
    Seobeo_Log(SEOBEO_ERROR,
               "[AUTH] Init failed: database path required\n");
    return FALSE;
  }

  g_auth_store = Auth_Store_Create(database_path);
  if (!g_auth_store)
  {
    Seobeo_Log(SEOBEO_ERROR, "[AUTH] Failed to open auth store\n");
    return FALSE;
  }

  memcpy(g_cookie_secret, cookie_secret, cookie_secret_length);
  g_cookie_secret_length = cookie_secret_length;

  if (trusted_proxy_ip && trusted_proxy_ip[0] != '\0')
  {
    /* Canonicalize via inet_pton/inet_ntop; reject invalid addresses. */
    struct in_addr  addr4;
    struct in6_addr addr6;
    char canonical[AUTH_CRYPTO_IP_MAX_BYTES];
    canonical[0] = '\0';
    if (inet_pton(AF_INET, trusted_proxy_ip, &addr4) == 1)
    {
      if (!inet_ntop(AF_INET, &addr4, canonical, sizeof(canonical)))
      {
        Seobeo_Log(SEOBEO_ERROR,
                   "[AUTH] Init failed: trusted proxy IPv4 canonicalization\n");
        Auth_Store_Destroy(g_auth_store);
        g_auth_store = NULL;
        return FALSE;
      }
    }
    else if (inet_pton(AF_INET6, trusted_proxy_ip, &addr6) == 1)
    {
      if (!inet_ntop(AF_INET6, &addr6, canonical, sizeof(canonical)))
      {
        Seobeo_Log(SEOBEO_ERROR,
                   "[AUTH] Init failed: trusted proxy IPv6 canonicalization\n");
        Auth_Store_Destroy(g_auth_store);
        g_auth_store = NULL;
        return FALSE;
      }
    }
    else
    {
      Seobeo_Log(SEOBEO_ERROR,
                 "[AUTH] Init failed: trusted proxy is not a valid IP address\n");
      Auth_Store_Destroy(g_auth_store);
      g_auth_store = NULL;
      return FALSE;
    }
    strncpy(g_trusted_proxy_ip, canonical, sizeof(g_trusted_proxy_ip) - 1);
    g_trusted_proxy_ip[sizeof(g_trusted_proxy_ip) - 1] = '\0';
    g_has_trusted_proxy = TRUE;
  }

  if (session_idle_ttl_secs > 0)
    g_session_idle_ttl = session_idle_ttl_secs;
  if (session_absolute_ttl_secs > 0)
    g_session_abs_ttl = session_absolute_ttl_secs;
  if (guest_ttl_secs > 0)
    g_guest_ttl = guest_ttl_secs;

  g_dev_insecure_cookie = dev_insecure_cookie;

  /* Bootstrap admin — fail closed on any store error. */
  if (bootstrap_username && bootstrap_username[0] != '\0' &&
      bootstrap_password_hash && bootstrap_password_hash[0] != '\0')
  {
    Auth_Store_Bootstrap_Result bootstrap_result;
    char bootstrap_id[37];
    Auth_Store_Result r = Auth_Store_Bootstrap_Admin(
        g_auth_store,
        bootstrap_username,
        bootstrap_password_hash,
        &bootstrap_result,
        bootstrap_id);

    if (r == AUTH_STORE_OK &&
        bootstrap_result == AUTH_STORE_BOOTSTRAP_CREATED)
    {
      Seobeo_Log(SEOBEO_INFO, "[AUTH] Bootstrap admin created\n");
    }
    else if (r == AUTH_STORE_OK &&
             bootstrap_result == AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT)
    {
      Seobeo_Log(SEOBEO_INFO, "[AUTH] Bootstrap admin already present\n");
    }
    else
    {
      Seobeo_Log(SEOBEO_ERROR,
                 "[AUTH] Bootstrap admin failed: store error %d — refusing to start\n", r);
      OPENSSL_cleanse(g_cookie_secret, sizeof(g_cookie_secret));
      g_cookie_secret_length = 0;
      g_has_trusted_proxy = FALSE;
      Auth_Store_Destroy(g_auth_store);
      g_auth_store = NULL;
      return FALSE;
    }
  }

  Seobeo_Log(SEOBEO_INFO, "[AUTH] Initialised (dev_insecure=%s)\n",
             dev_insecure_cookie ? "yes" : "no");
  return TRUE;
}

void Auth_API_Destroy(void)
{
  if (g_auth_store)
  {
    Auth_Store_Destroy(g_auth_store);
    g_auth_store = NULL;
  }
  OPENSSL_cleanse(g_cookie_secret, sizeof(g_cookie_secret));
  g_cookie_secret_length = 0;
  g_has_trusted_proxy    = FALSE;
  g_transfer_hook        = NULL;
  g_transfer_hook_ctx    = NULL;
  g_guest_quota_cb       = NULL;
#ifdef AUTH_API_TEST_HOOKS
  g_login_pre_create_hook = NULL;
  g_login_pre_create_context = NULL;
#endif
}

void Auth_API_Register_Routes(void)
{
  Seobeo_Router_Register("GET",  "/api/auth/session",  auth_session_handler);
  Seobeo_Router_Register("POST", "/api/auth/login",    auth_login_handler);
  Seobeo_Router_Register("POST", "/api/auth/logout",   auth_logout_handler);
  Seobeo_Router_Register("POST", "/api/auth/password", auth_password_handler);
  Seobeo_Router_Register("GET",  "/account/password",  auth_password_page_handler);
}

Auth_Store *Auth_API_Get_Store(void)
{
  return g_auth_store;
}

/* ------------------------------------------------------------------ */
/* Test hooks (compiled in only for test builds)                        */
/* ------------------------------------------------------------------ */

#ifdef AUTH_API_TEST_HOOKS
void Auth_API_Test_Set_Login_Pre_Create_Hook(
    Auth_API_Test_Login_Pre_Create_Hook hook,
    void                               *p_context)
{
  g_login_pre_create_hook = hook;
  g_login_pre_create_context = p_context;
}

Seobeo_Request_Entry *Auth_API_Test_Session_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return auth_session_handler(p_req, p_arena); }

Seobeo_Request_Entry *Auth_API_Test_Login_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return auth_login_handler(p_req, p_arena); }

Seobeo_Request_Entry *Auth_API_Test_Logout_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return auth_logout_handler(p_req, p_arena); }

Seobeo_Request_Entry *Auth_API_Test_Password_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return auth_password_handler(p_req, p_arena); }
#endif /* AUTH_API_TEST_HOOKS */