User authentication and session management for web applications
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add purge_expired_sessions, document security assumptions in Auth

- purge_expired_sessions: deletes all expired/corrupted sessions.
Call periodically to prevent unbounded table growth.
- Document: 256-bit token space, session fixation prevention, session
rotation on sign-in, single-instance SQLite requirement, cleanup
strategy.

34/34 auth tests pass.

+53 -14
+30 -13
lib/auth.ml
··· 232 232 to_delete := token :: !to_delete 233 233 | _ -> ())); 234 234 List.iter (fun token -> Sqlite.Table.delete t.sessions token) !to_delete 235 + 236 + let purge_expired_sessions t ~secret = 237 + let now = Unix.gettimeofday () in 238 + let to_delete = ref [] in 239 + Sqlite.Table.iter t.sessions ~f:(fun token signed_value -> 240 + match verify_session_value ~secret signed_value with 241 + | None -> to_delete := token :: !to_delete 242 + | Some value -> ( 243 + match String.split_on_char ':' value with 244 + | [ _; exp_s ] -> ( 245 + match float_of_string_opt exp_s with 246 + | Some exp when now > exp -> to_delete := token :: !to_delete 247 + | _ -> ()) 248 + | _ -> to_delete := token :: !to_delete)); 249 + let n = List.length !to_delete in 250 + List.iter (fun token -> Sqlite.Table.delete t.sessions token) !to_delete; 251 + n 235 252 end 236 253 237 254 (* ── Cookie helpers ──────────────────────────────────────────────── *) ··· 248 265 ( "Set-Cookie", 249 266 Fmt.str "%s=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" cookie_name ) 250 267 268 + let parse_cookie_value ~name cookie_str = 269 + let pairs = String.split_on_char ';' cookie_str in 270 + List.find_map 271 + (fun pair -> 272 + let pair = String.trim pair in 273 + match String.index_opt pair '=' with 274 + | Some i when String.trim (String.sub pair 0 i) = name -> 275 + Some 276 + (String.trim (String.sub pair (i + 1) (String.length pair - i - 1))) 277 + | _ -> None) 278 + pairs 279 + 251 280 let extract_session_token (headers : Headers.t) = 252 281 match Headers.string "cookie" headers with 253 282 | None -> None 254 - | Some cookie_str -> 255 - (* Simple cookie parsing: find sid=<value> *) 256 - let pairs = String.split_on_char ';' cookie_str in 257 - List.find_map 258 - (fun pair -> 259 - let pair = String.trim pair in 260 - match String.index_opt pair '=' with 261 - | Some i when String.trim (String.sub pair 0 i) = cookie_name -> 262 - Some 263 - (String.trim 264 - (String.sub pair (i + 1) (String.length pair - i - 1))) 265 - | _ -> None) 266 - pairs 283 + | Some cookie_str -> parse_cookie_value ~name:cookie_name cookie_str 267 284 268 285 (* ── Middleware ───────────────────────────────────────────────────── *) 269 286
+23 -1
lib/auth.mli
··· 8 8 {e HttpOnly}, {e SameSite=Lax}, and {e Secure} (when base URL is HTTPS). 9 9 CSRF protection on the OAuth callback uses signed state tokens via {!Csrf}. 10 10 11 + {2 Security Assumptions} 12 + 13 + - {b Token space}: Session tokens are 256 bits (32 random bytes, hex-encoded 14 + to 64 chars). Brute-force guessing requires 2{^ 256} attempts. 15 + - {b Session fixation}: Not possible — tokens are server-generated, never 16 + accepted from user input. 17 + - {b Session rotation}: On sign-in, all existing sessions for the user are 18 + revoked. A compromised token is invalidated when the user signs in again. 19 + - {b Single instance}: The SQLite session store requires single-process 20 + access. SQLite locking breaks on network filesystems (NFS). Do not share 21 + the auth database between instances. For multi-instance deployments, use a 22 + shared session store (Redis, Postgres). 23 + - {b Session cleanup}: Expired sessions are deleted on lookup, but abandoned 24 + sessions accumulate. Call {!Store.purge_expired_sessions} periodically. 25 + 11 26 {2 Quick start} 12 27 13 28 {[ ··· 156 171 157 172 val delete_user_sessions : t -> secret:string -> user_id:int -> unit 158 173 (** [delete_user_sessions store ~secret ~user_id] revokes all sessions for 159 - [user_id]. Use for "sign out everywhere". *) 174 + [user_id]. Use for "sign out everywhere". Called automatically on sign-in 175 + (session rotation). *) 176 + 177 + val purge_expired_sessions : t -> secret:string -> int 178 + (** [purge_expired_sessions store ~secret] deletes all expired and corrupted 179 + sessions. Returns the number of sessions purged. Call periodically (e.g. 180 + daily) to prevent unbounded session table growth from abandoned sessions 181 + that are never looked up again. *) 160 182 end 161 183 162 184 (** {1:cookies Cookie helpers} *)