diff auth/auth_store.h @ 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/auth/auth_store.h	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,462 @@
+#ifndef ZENBU_AUTH_STORE_H
+#define ZENBU_AUTH_STORE_H
+
+#include "dowa/dowa.h"
+#include "auth/auth_crypto.h"
+
+#define AUTH_STORE_USERNAME_MIN 3
+#define AUTH_STORE_USERNAME_MAX 32
+
+typedef struct Auth_Store Auth_Store;
+
+typedef enum {
+  AUTH_STORE_ERROR          = -1,
+  AUTH_STORE_OK             = 0,
+  AUTH_STORE_NOT_FOUND      = 1,
+  AUTH_STORE_CONFLICT       = 2,
+  AUTH_STORE_EXPIRED        = 3,
+  AUTH_STORE_REVOKED        = 4,
+  AUTH_STORE_USER_DISABLED  = 5,
+  AUTH_STORE_STALE_PASSWORD = 6,
+  AUTH_STORE_LAST_ADMIN     = 7,
+  AUTH_STORE_INVALID_ARG    = 8,
+} Auth_Store_Result;
+
+typedef enum {
+  AUTH_STORE_BOOTSTRAP_CREATED       = 0,
+  AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT = 1,
+} Auth_Store_Bootstrap_Result;
+
+/*
+ * Public user record — password_hash is never included here.
+ * All fields are fixed-size; no arena pointer is needed.
+ */
+typedef struct {
+  char    id[37];
+  char    username[AUTH_STORE_USERNAME_MAX + 1];
+  char    normalized_username[AUTH_STORE_USERNAME_MAX + 1];
+  char    role[8];    /* "admin" or "member" */
+  char    status[9];  /* "active" or "disabled" */
+  boolean must_change_password;
+  int64   password_changed_at;
+  int64   created_at;
+  int64   updated_at;
+} Auth_User_Record;
+
+/*
+ * Authentication lookup record — includes the encoded password hash.
+ * Callers must zero this struct after use; never log the hash field.
+ */
+typedef struct {
+  Auth_User_Record user;
+  char             password_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+} Auth_User_Auth_Record;
+
+/*
+ * Session record — token/CSRF digests are not exposed here; the caller
+ * already holds them before calling Create_Session or Find_Session.
+ */
+typedef struct {
+  char  user_id[37];
+  int64 created_at;
+  int64 last_seen_at;
+  int64 idle_expires_at;
+  int64 absolute_expires_at;
+  int64 password_changed_at_snapshot;
+} Auth_Session_Record;
+
+typedef struct {
+  char  id[37];
+  int64 created_at;
+  int64 last_seen_at;
+  int64 expires_at;
+} Auth_Guest_Identity_Record;
+
+/* --- Store lifecycle --- */
+
+Auth_Store *Auth_Store_Create(const char *database_path);
+void        Auth_Store_Destroy(Auth_Store *p_store);
+
+/* --- Username utilities --- */
+
+/*
+ * Trim ASCII spaces, lowercase, validate character set and length.
+ * Writes the normalized form to 'normalized' (capacity must include NUL).
+ * Returns TRUE on success; FALSE if invalid or buffer too small.
+ */
+boolean Auth_Store_Normalize_Username(
+    const char *username,
+    char       *normalized,
+    size_t      capacity);
+
+/*
+ * Validate a pre-normalized username (lowercase, no leading/trailing spaces).
+ * Returns TRUE iff length and character set are within policy.
+ */
+boolean Auth_Store_Validate_Username(const char *normalized_username);
+
+/* --- User management --- */
+
+Auth_Store_Result Auth_Store_Create_User(
+    Auth_Store *p_store,
+    const char *username,
+    const char *encoded_hash,
+    const char *role,
+    boolean     must_change_password,
+    char        output_id[37]);
+
+/* Create a user and its audit record in one transaction. */
+Auth_Store_Result Auth_Store_Create_User_Audited(
+    Auth_Store *p_store,
+    const char *username,
+    const char *encoded_hash,
+    const char *role,
+    boolean     must_change_password,
+    const char *actor_user_id,
+    char        output_id[37]);
+
+/*
+ * Idempotent: creates an admin user only when no admin exists at all.
+ * p_bootstrap_result receives CREATED or ALREADY_PRESENT.
+ * output_id receives the UUID (new or existing) and may be NULL.
+ */
+Auth_Store_Result Auth_Store_Bootstrap_Admin(
+    Auth_Store               *p_store,
+    const char               *username,
+    const char               *encoded_hash,
+    Auth_Store_Bootstrap_Result *p_bootstrap_result,
+    char                      output_id[37]);
+
+/* Includes password_hash for authentication — zero record after use. */
+Auth_Store_Result Auth_Store_Find_User_By_Username(
+    Auth_Store         *p_store,
+    const char         *username,
+    Auth_User_Auth_Record *p_record);
+
+Auth_Store_Result Auth_Store_Get_User(
+    Auth_Store       *p_store,
+    const char       *user_id,
+    Auth_User_Record *p_record);
+
+/*
+ * List all users into a Dowa arena-backed array.
+ * After a successful call, use Dowa_Array_Length(*pp_records) for count.
+ */
+Auth_Store_Result Auth_Store_List_Users(
+    Auth_Store        *p_store,
+    Auth_User_Record **pp_records,
+    Dowa_Arena        *p_arena);
+
+/*
+ * new_status must be "active" or "disabled".
+ * Fails with AUTH_STORE_LAST_ADMIN if disabling the last active admin.
+ */
+Auth_Store_Result Auth_Store_Update_User_Status(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_status,
+    const char *actor_user_id);
+
+/* Atomically enable a user and write audit; revoked sessions stay revoked. */
+Auth_Store_Result Auth_Store_Enable_User(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *actor_user_id);
+
+/* Atomically enforce last-admin policy, disable, revoke, and write audit. */
+Auth_Store_Result Auth_Store_Disable_User_And_Revoke_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *actor_user_id);
+
+/*
+ * new_role must be "admin" or "member".
+ * Fails with AUTH_STORE_LAST_ADMIN if demoting the last active admin.
+ */
+Auth_Store_Result Auth_Store_Update_User_Role(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_role,
+    const char *actor_user_id);
+
+/* Atomically enforce last-admin policy, update role, revoke, and audit. */
+Auth_Store_Result Auth_Store_Update_Role_And_Revoke_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_role,
+    const char *actor_user_id);
+
+Auth_Store_Result Auth_Store_Set_Must_Change_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    boolean     value,
+    const char *actor_user_id);
+
+/*
+ * Updates the encoded password hash; clears must_change_password.
+ * If revoke_other_sessions is TRUE, all sessions except keep_token_digest
+ * (which may be NULL) are revoked atomically in the same transaction.
+ */
+Auth_Store_Result Auth_Store_Update_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_encoded_hash,
+    boolean     revoke_other_sessions,
+    const char *keep_token_digest);
+
+/* --- Session management --- */
+
+/*
+ * Creates a new session.  Fails if the user is disabled.
+ * current_unix is the caller-supplied Unix timestamp.
+ */
+Auth_Store_Result Auth_Store_Create_Session(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *token_digest,
+    const char          *csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record);
+
+/*
+ * CAS session create: only creates a session if the user's current
+ * password_hash still exactly matches expected_password_hash.
+ * Returns AUTH_STORE_STALE_PASSWORD when an administrator changed it.
+ * The caller must cleanse expected_password_hash after this call.
+ */
+Auth_Store_Result Auth_Store_Create_Session_CAS(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *expected_password_hash,
+    const char          *token_digest,
+    const char          *csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record);
+
+/*
+ * Resolves a session by token_digest.
+ * Returns:
+ *   OK             — session is valid; p_session and p_user are populated.
+ *   NOT_FOUND      — no such session.
+ *   REVOKED        — session was explicitly revoked.
+ *   EXPIRED        — idle or absolute expiry exceeded.
+ *   USER_DISABLED  — session owner has been disabled.
+ *   STALE_PASSWORD — password changed after session was created.
+ */
+Auth_Store_Result Auth_Store_Find_Session(
+    Auth_Store          *p_store,
+    const char          *token_digest,
+    int64                current_unix,
+    Auth_Session_Record *p_session,
+    Auth_User_Record    *p_user);
+
+/* Extends the idle expiry; does nothing if the session is revoked. */
+Auth_Store_Result Auth_Store_Touch_Session(
+    Auth_Store *p_store,
+    const char *token_digest,
+    int64       current_unix,
+    int64       idle_ttl_secs);
+
+Auth_Store_Result Auth_Store_Revoke_Session(
+    Auth_Store *p_store,
+    const char *token_digest);
+
+/*
+ * Revokes all sessions for user_id.
+ * If except_token_digest is non-NULL, that session is preserved.
+ */
+Auth_Store_Result Auth_Store_Revoke_All_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *except_token_digest);
+
+/* Atomically revoke sessions and write one audit record. */
+Auth_Store_Result Auth_Store_Revoke_All_Sessions_Audited(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *except_token_digest,
+    const char *actor_user_id);
+
+/*
+ * Atomically create a new session and revoke the old one in a single
+ * transaction.  Use this after a password change to rotate the current
+ * session without any window where neither or both sessions are valid.
+ *
+ * old_token_digest: the existing session to revoke (must not be NULL or "").
+ *   If old_token_digest is already revoked the rotation still succeeds and
+ *   the new session is created.
+ * All other parameters are the same as Auth_Store_Create_Session.
+ */
+Auth_Store_Result Auth_Store_Rotate_Session(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *old_token_digest,
+    const char          *new_token_digest,
+    const char          *new_csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record);
+
+/*
+ * Atomically compare-and-swap the password hash, clear forced change,
+ * revoke every existing session, and create one replacement session.
+ * On AUTH_STORE_STALE_PASSWORD no state is changed. The caller must cleanse
+ * both password hashes after this call.
+ */
+Auth_Store_Result Auth_Store_Self_Change_Password(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *old_encoded_hash,
+    const char          *new_encoded_hash,
+    const char          *new_token_digest,
+    const char          *new_csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record);
+
+/* --- Guest quota --- */
+
+typedef enum {
+  AUTH_STORE_GUEST_QUOTA_OK               =  0,
+  AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED  =  1,
+  AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED =  2,
+  AUTH_STORE_GUEST_QUOTA_ERROR            = -1,
+} Auth_Store_Guest_Quota_Result;
+
+typedef struct {
+  int64 turns_used;
+  int64 output_tokens_used;
+  int64 output_tokens_reserved;
+} Auth_Store_Guest_Usage;
+
+/*
+ * Read current daily usage for a guest window.
+ * window_start: UTC midnight Unix timestamp for the current day.
+ * Returns OK with zeroed usage if no row exists yet, or ERROR.
+ */
+Auth_Store_Result Auth_Store_Guest_Get_Usage(
+    Auth_Store             *p_store,
+    const char             *guest_id,
+    int64                   window_start,
+    Auth_Store_Guest_Usage *p_usage);
+
+/*
+ * Atomically reserve quota for one inference turn.
+ * On success: increments turns_used by 1 and output_tokens_reserved by
+ *   max_output_tokens in the usage row for window_start.
+ * Invariant after every reservation:
+ *   output_tokens_used + output_tokens_reserved <= tokens_limit.
+ * Returns TURNS_EXHAUSTED or TOKENS_EXHAUSTED when the limit would be exceeded.
+ * reservation_expires: Unix timestamp after which the reservation may be
+ *   discarded by cleanup; the turn charge (turns_used) persists regardless.
+ */
+Auth_Store_Guest_Quota_Result Auth_Store_Guest_Reserve(
+    Auth_Store *p_store,
+    const char *guest_id,
+    const char *request_id,
+    int64       window_start,
+    int64       max_output_tokens,
+    int64       turns_limit,
+    int64       tokens_limit,
+    int64       reservation_expires);
+
+/*
+ * Reconcile a completed turn with the actual provider output token count.
+ * actual_output_tokens is capped to the reserved amount (fail closed; if
+ * the provider reports more than reserved, the reserved amount is charged).
+ * Adds capped tokens to output_tokens_used; subtracts reserved from
+ * output_tokens_reserved; deletes the reservation row.
+ * Idempotent by request_id: safe to call when already reconciled.
+ */
+Auth_Store_Result Auth_Store_Guest_Reconcile(
+    Auth_Store *p_store,
+    const char *request_id,
+    int64       actual_output_tokens);
+
+/*
+ * Release a reservation on failure or abort.
+ * Decrements output_tokens_reserved by the reserved amount; does NOT
+ * change turns_used (the turn charge is retained).
+ * Deletes the reservation row.
+ * Idempotent by request_id.
+ */
+Auth_Store_Result Auth_Store_Guest_Release(
+    Auth_Store *p_store,
+    const char *request_id);
+
+/*
+ * Clear all outstanding reservations for a guest.
+ * Used after a successful guest→user login transfer.
+ * Decrements output_tokens_reserved for each affected usage window.
+ * Does NOT change turns_used.
+ */
+Auth_Store_Result Auth_Store_Guest_Clear_Reservations(
+    Auth_Store *p_store,
+    const char *guest_id);
+
+/*
+ * Atomically reap expired reservations: decrement output_tokens_reserved
+ * in the matching guest_usage rows and delete expired reservation rows, all
+ * in one transaction.  Uses MAX(0,...) to avoid underflow.
+ * Idempotent: safe to call repeatedly with the same or decreasing current_unix.
+ * Called at store startup and before quota read/reserve operations.
+ */
+Auth_Store_Result Auth_Store_Guest_Reap_Expired(
+    Auth_Store *p_store,
+    int64       current_unix);
+
+/* --- Guest identity --- */
+
+/*
+ * Insert or refresh a guest identity.
+ * ip_binding_digest must be an HMAC digest — never the raw IP address.
+ * last_seen_at is updated to now; expires_at is refreshed.
+ */
+Auth_Store_Result Auth_Store_Upsert_Guest_Identity(
+    Auth_Store                *p_store,
+    const char                *guest_id,
+    const char                *ip_binding_digest,
+    int64                      expires_at,
+    Auth_Guest_Identity_Record *p_record);
+
+Auth_Store_Result Auth_Store_Find_Guest_Identity(
+    Auth_Store                *p_store,
+    const char                *guest_id,
+    int64                      current_unix,
+    Auth_Guest_Identity_Record *p_record);
+
+/* --- Audit log --- */
+
+/*
+ * Append one bounded audit entry.  actor/target/detail may be NULL.
+ * Never include passwords, hashes, tokens, or session digests.
+ * Returns OK or ERROR.
+ */
+Auth_Store_Result Auth_Store_Insert_Audit_Log(
+    Auth_Store *p_store,
+    const char *actor_user_id,
+    const char *action,
+    const char *target_user_id,
+    const char *detail);
+
+/* --- Admin operations --- */
+
+/*
+ * Atomically: update password hash, set must_change_password=1,
+ * revoke all target sessions, write audit row.
+ * Use for admin-initiated temporary-password reset only.
+ * new_encoded_hash must be a valid encoded zenbu-scrypt hash.
+ */
+Auth_Store_Result Auth_Store_Admin_Reset_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_encoded_hash,
+    const char *actor_user_id);
+
+#endif