Mercurial
diff mrjunejune/auth_api.c @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/auth_api.c Fri Aug 07 07:34:12 2026 -0700 @@ -0,0 +1,2019 @@ +#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 */