ocaml http/1, http/2 and websocket client and server library

feat: add session, endpoint, pubsub, channel modules

- Session plug with fiber-local storage and kcas for concurrency safety
- Token plug for HMAC signing and AEAD encryption
- Negotiate plug for content negotiation (Accept header handling)
- Endpoint module for application bootstrap (router + plugs + server)
- PubSub module for lock-free topic-based messaging
- Channel module for WebSocket topic subscriptions

+3 -2
dune-project
··· 40 40 (digestif (>= 1.2)) 41 41 (base64 (>= 3.5)) 42 42 (bigstringaf (>= 0.10)) 43 - (faraday (>= 0.8)) 44 - (climate (>= 0.9)) 43 + (faraday (>= 0.8)) 44 + (kcas (>= 0.7)) 45 + (climate (>= 0.9)) 45 46 (zlib (>= 0.8)) 46 47 (zstd (>= 0.4)) 47 48 (alcotest (and (>= 1.9) :with-test))
+139
lib/channel.ml
··· 1 + type event = { 2 + topic : string; 3 + event : string; 4 + payload : string; 5 + ref : int option; 6 + } 7 + 8 + type t = { 9 + ws : Websocket.t; 10 + pubsub : Pubsub.t; 11 + subscriptions : (string, Pubsub.subscription) Kcas_data.Hashtbl.t; 12 + handlers : (string, event -> unit) Kcas_data.Hashtbl.t; 13 + mutable next_ref : int; 14 + } 15 + 16 + let parse_event s = 17 + let len = String.length s in 18 + if len < 2 || s.[0] <> '{' then None 19 + else 20 + let find_string_field key = 21 + let pattern = "\"" ^ key ^ "\":\"" in 22 + let plen = String.length pattern in 23 + let rec search i = 24 + if i + plen >= len then None 25 + else if String.sub s i plen = pattern then 26 + let start = i + plen in 27 + let rec find_end j = 28 + if j >= len then None 29 + else if s.[j] = '"' && (j = start || s.[j - 1] <> '\\') then 30 + Some (String.sub s start (j - start)) 31 + else find_end (j + 1) 32 + in 33 + find_end start 34 + else search (i + 1) 35 + in 36 + search 0 37 + in 38 + let find_int_field key = 39 + let pattern = "\"" ^ key ^ "\":" in 40 + let plen = String.length pattern in 41 + let rec search i = 42 + if i + plen >= len then None 43 + else if String.sub s i plen = pattern then 44 + let start = i + plen in 45 + let rec find_end j = 46 + if j >= len then j 47 + else match s.[j] with '0' .. '9' -> find_end (j + 1) | _ -> j 48 + in 49 + let end_pos = find_end start in 50 + if end_pos > start then 51 + int_of_string_opt (String.sub s start (end_pos - start)) 52 + else None 53 + else search (i + 1) 54 + in 55 + search 0 56 + in 57 + match (find_string_field "t", find_string_field "e") with 58 + | Some topic, Some event -> 59 + let payload = Option.value ~default:"" (find_string_field "p") in 60 + let ref = find_int_field "r" in 61 + Some { topic; event; payload; ref } 62 + | _ -> None 63 + 64 + let serialize_event ev = 65 + let ref_part = 66 + match ev.ref with Some r -> Printf.sprintf ",\"r\":%d" r | None -> "" 67 + in 68 + Printf.sprintf "{\"t\":\"%s\",\"e\":\"%s\",\"p\":\"%s\"%s}" ev.topic ev.event 69 + ev.payload ref_part 70 + 71 + let create ~ws ~pubsub () = 72 + { 73 + ws; 74 + pubsub; 75 + subscriptions = Kcas_data.Hashtbl.create (); 76 + handlers = Kcas_data.Hashtbl.create (); 77 + next_ref = 0; 78 + } 79 + 80 + let join t ~topic ~params:_ = 81 + if Kcas_data.Hashtbl.mem t.subscriptions topic then Ok () 82 + else 83 + let sub = 84 + Pubsub.subscribe t.pubsub topic (fun msg -> 85 + let ev = { topic; event = "msg"; payload = msg; ref = None } in 86 + ignore (Websocket.send_text t.ws (serialize_event ev))) 87 + in 88 + Kcas_data.Hashtbl.replace t.subscriptions topic sub; 89 + Ok () 90 + 91 + let leave t ~topic = 92 + match Kcas_data.Hashtbl.find_opt t.subscriptions topic with 93 + | None -> () 94 + | Some sub -> 95 + Pubsub.unsubscribe t.pubsub sub; 96 + Kcas_data.Hashtbl.remove t.subscriptions topic 97 + 98 + let push t ~topic ~event ~payload = 99 + let ref = t.next_ref in 100 + t.next_ref <- t.next_ref + 1; 101 + let ev = { topic; event; payload; ref = Some ref } in 102 + ignore (Websocket.send_text t.ws (serialize_event ev)) 103 + 104 + let broadcast t ~topic ~payload = Pubsub.broadcast t.pubsub topic payload 105 + let on t ~event ~handler = Kcas_data.Hashtbl.replace t.handlers event handler 106 + 107 + let handle_message t msg = 108 + match parse_event msg with 109 + | None -> () 110 + | Some ev -> ( 111 + match ev.event with 112 + | "join" -> ignore (join t ~topic:ev.topic ~params:ev.payload) 113 + | "leave" -> leave t ~topic:ev.topic 114 + | _ -> ( 115 + match Kcas_data.Hashtbl.find_opt t.handlers ev.event with 116 + | Some handler -> handler ev 117 + | None -> ())) 118 + 119 + let close t = 120 + Kcas_data.Hashtbl.iter 121 + (fun _ sub -> Pubsub.unsubscribe t.pubsub sub) 122 + t.subscriptions; 123 + Kcas_data.Hashtbl.clear t.subscriptions; 124 + Websocket.close t.ws 125 + 126 + let run t = 127 + let rec loop () = 128 + match Websocket.recv_message t.ws with 129 + | Ok (Websocket.Opcode.Text, msg) -> 130 + handle_message t msg; 131 + loop () 132 + | Ok (Websocket.Opcode.Binary, msg) -> 133 + handle_message t msg; 134 + loop () 135 + | Ok _ -> loop () 136 + | Error Websocket.Connection_closed -> close t 137 + | Error _ -> close t 138 + in 139 + loop ()
+1 -1
lib/dune
··· 3 3 (library 4 4 (name hcs) 5 5 (public_name hcs) 6 - (libraries eio eio.unix h1 h2 tls-eio tls ca-certs x509 ptime ptime.clock.os cstruct uri digestif base64 bigstringaf faraday bytesrw bytesrw.zlib bytesrw.zstd)) 6 + (libraries eio eio.unix h1 h2 tls-eio tls ca-certs x509 ptime ptime.clock.os cstruct uri digestif base64 bigstringaf faraday bytesrw bytesrw.zlib bytesrw.zstd mirage-crypto mirage-crypto-rng kcas kcas_data unix))
+84
lib/endpoint.ml
··· 1 + type config = { 2 + port : int; 3 + bind : string; 4 + domains : int; 5 + secret_key_base : string; 6 + protocol : Server.protocol; 7 + tls : Tls_config.Server.t option; 8 + health_check : bool; 9 + } 10 + 11 + let default_config = 12 + { 13 + port = 8080; 14 + bind = "0.0.0.0"; 15 + domains = 1; 16 + secret_key_base = ""; 17 + protocol = Server.Http1_only; 18 + tls = None; 19 + health_check = true; 20 + } 21 + 22 + type params_handler = Router.params -> Server.request -> Server.response 23 + 24 + type t = { 25 + config : config; 26 + plugs : Plug.t list; 27 + router : params_handler Router.t option; 28 + ws_handler : Server.ws_handler option; 29 + } 30 + 31 + let create config = { config; plugs = []; router = None; ws_handler = None } 32 + let plug t p = { t with plugs = p :: t.plugs } 33 + let router t r = { t with router = Some r } 34 + let websocket t handler = { t with ws_handler = Some handler } 35 + let not_found_handler _req = Server.respond ~status:`Not_found "Not Found" 36 + 37 + let health_handler _req = 38 + Server.respond ~status:`OK ~headers:[ ("Content-Type", "text/plain") ] "ok" 39 + 40 + let build_handler t = 41 + let base_handler = 42 + match t.router with 43 + | None -> not_found_handler 44 + | Some r -> ( 45 + fun req -> 46 + let path = 47 + match String.index_opt req.Server.target '?' with 48 + | Some i -> String.sub req.target 0 i 49 + | None -> req.target 50 + in 51 + match Router.lookup r ~method_:req.meth ~path with 52 + | Some (handler, params) -> handler params req 53 + | None -> not_found_handler req) 54 + in 55 + let with_health = 56 + if t.config.health_check then fun req -> 57 + if req.Server.target = "/_health" || req.Server.target = "/health" then 58 + health_handler req 59 + else base_handler req 60 + else base_handler 61 + in 62 + let pipeline = Plug.compose_all (List.rev t.plugs) in 63 + Plug.apply pipeline with_health 64 + 65 + let start t ~env = 66 + let handler = build_handler t in 67 + let server_config = 68 + { 69 + Server.default_config with 70 + port = t.config.port; 71 + host = t.config.bind; 72 + domain_count = t.config.domains; 73 + protocol = t.config.protocol; 74 + tls = t.config.tls; 75 + } 76 + in 77 + Eio.Switch.run @@ fun sw -> 78 + let net = Eio.Stdenv.net env in 79 + if t.config.domains > 1 then 80 + let domain_mgr = Eio.Stdenv.domain_mgr env in 81 + Server.run_parallel ~sw ~net ~domain_mgr ~config:server_config 82 + ?ws_handler:t.ws_handler handler 83 + else 84 + Server.run ~sw ~net ~config:server_config ?ws_handler:t.ws_handler handler
+9
lib/hcs.ml
··· 18 18 module Plug = Plug 19 19 (** Plug-based middleware system (Phoenix-style) *) 20 20 21 + module Endpoint = Endpoint 22 + (** Application bootstrap tying router, plugs, and server *) 23 + 21 24 module Middleware = Middleware 22 25 (** Generic middleware composition (for non-HTTP use cases) *) 23 26 ··· 50 53 51 54 module Websocket = Websocket 52 55 (** WebSocket support *) 56 + 57 + module Pubsub = Pubsub 58 + (** Lock-free topic-based pub/sub messaging *) 59 + 60 + module Channel = Channel 61 + (** WebSocket channel abstraction with topic subscriptions *) 53 62 54 63 module Codec = Codec 55 64 (** Codec system for serialization/deserialization *)
+3
lib/plug.ml
··· 33 33 module Retry = Retry 34 34 module Basic_auth = Basic_auth 35 35 module Csrf = Csrf 36 + module Negotiate = Negotiate 37 + module Token = Token 38 + module Session = Session
+401
lib/plug/negotiate.ml
··· 1 + (** Content negotiation plug. 2 + 3 + Parses Accept headers and selects response format based on client 4 + preferences and server capabilities. Follows RFC 7231 content negotiation 5 + semantics. 6 + 7 + {1 Usage} 8 + 9 + {[ 10 + (* In router pipeline *) 11 + let pipeline = 12 + Plug.Negotiate.create ~formats:[ Json; Html ] () @> Plug.identity 13 + 14 + (* In handler *) 15 + let handler req = 16 + match get_format req with 17 + | Some Json -> Response.json {|{"message": "hello"}|} 18 + | Some Html -> Response.html "<h1>Hello</h1>" 19 + | _ -> Response.text "hello" 20 + 21 + (* Or use respond helper *) 22 + let handler req = 23 + respond req 24 + ~json:(fun () -> {|{"message": "hello"}|}) 25 + ~html:(fun () -> "<h1>Hello</h1>") 26 + ]} *) 27 + 28 + (** {1 Types} *) 29 + 30 + type media_type = { 31 + type_ : string; (** e.g., "application" *) 32 + subtype : string; (** e.g., "json" *) 33 + quality : float; (** 0.0 - 1.0, default 1.0 *) 34 + params : (string * string) list; (** Additional parameters *) 35 + } 36 + (** Parsed media type from Accept header *) 37 + 38 + (** Response format - common formats plus custom *) 39 + type format = 40 + | Json 41 + | Html 42 + | Text 43 + | Xml 44 + | Csv 45 + | Custom of string (** Custom MIME type *) 46 + 47 + (** Content negotiation errors *) 48 + type error = Not_acceptable 49 + 50 + exception Not_acceptable_exn 51 + 52 + (** {1 Format Utilities} *) 53 + 54 + (** Get MIME type string for a format *) 55 + let mime_type_of_format = function 56 + | Json -> "application/json" 57 + | Html -> "text/html" 58 + | Text -> "text/plain" 59 + | Xml -> "application/xml" 60 + | Csv -> "text/csv" 61 + | Custom s -> s 62 + 63 + (** Get format from MIME type string *) 64 + let format_of_mime_type s = 65 + match String.lowercase_ascii s with 66 + | "application/json" -> Some Json 67 + | "text/json" -> Some Json 68 + | "text/html" -> Some Html 69 + | "text/plain" -> Some Text 70 + | "application/xml" -> Some Xml 71 + | "text/xml" -> Some Xml 72 + | "text/csv" -> Some Csv 73 + | _ -> Some (Custom s) 74 + 75 + (** Get short name for format (for internal use) *) 76 + let format_name = function 77 + | Json -> "json" 78 + | Html -> "html" 79 + | Text -> "text" 80 + | Xml -> "xml" 81 + | Csv -> "csv" 82 + | Custom s -> s 83 + 84 + (** {1 Accept Header Parsing} *) 85 + 86 + (** Skip whitespace in string starting at position i *) 87 + let skip_ws s i = 88 + let len = String.length s in 89 + let rec loop i = 90 + if i < len && (s.[i] = ' ' || s.[i] = '\t') then loop (i + 1) else i 91 + in 92 + loop i 93 + 94 + (** Parse a token (sequence of non-separator chars) *) 95 + let parse_token s i = 96 + let len = String.length s in 97 + let rec loop j = 98 + if j >= len then j 99 + else 100 + match s.[j] with 101 + | ' ' | '\t' | ',' | ';' | '=' | '/' -> j 102 + | _ -> loop (j + 1) 103 + in 104 + let j = loop i in 105 + (String.sub s i (j - i), j) 106 + 107 + (** Parse a quoted string *) 108 + let parse_quoted s i = 109 + let len = String.length s in 110 + if i >= len || s.[i] <> '"' then ("", i) 111 + else 112 + let buf = Buffer.create 32 in 113 + let rec loop j = 114 + if j >= len then j 115 + else if s.[j] = '"' then j + 1 116 + else if s.[j] = '\\' && j + 1 < len then begin 117 + Buffer.add_char buf s.[j + 1]; 118 + loop (j + 2) 119 + end 120 + else begin 121 + Buffer.add_char buf s.[j]; 122 + loop (j + 1) 123 + end 124 + in 125 + let j = loop (i + 1) in 126 + (Buffer.contents buf, j) 127 + 128 + (** Parse a parameter value (token or quoted-string) *) 129 + let parse_value s i = 130 + let i = skip_ws s i in 131 + if i < String.length s && s.[i] = '"' then parse_quoted s i 132 + else parse_token s i 133 + 134 + (** Parse parameters after media type: ;name=value pairs *) 135 + let parse_params s i = 136 + let len = String.length s in 137 + let rec loop i acc quality = 138 + let i = skip_ws s i in 139 + if i >= len || s.[i] = ',' then (List.rev acc, quality, i) 140 + else if s.[i] = ';' then begin 141 + let i = skip_ws s (i + 1) in 142 + let name, i = parse_token s i in 143 + let name = String.lowercase_ascii name in 144 + let i = skip_ws s i in 145 + if i < len && s.[i] = '=' then begin 146 + let value, i = parse_value s (i + 1) in 147 + if name = "q" then 148 + let q = try Float.of_string value with _ -> 1.0 in 149 + let q = Float.max 0.0 (Float.min 1.0 q) in 150 + loop i acc q 151 + else loop i ((name, value) :: acc) quality 152 + end 153 + else loop i acc quality 154 + end 155 + else (List.rev acc, quality, i) 156 + in 157 + loop i [] 1.0 158 + 159 + (** Parse a single media type entry *) 160 + let parse_media_type s i = 161 + let i = skip_ws s i in 162 + let type_, i = parse_token s i in 163 + let type_ = String.lowercase_ascii type_ in 164 + let i = skip_ws s i in 165 + if i >= String.length s || s.[i] <> '/' then None 166 + else begin 167 + let subtype, i = parse_token s (i + 1) in 168 + let subtype = String.lowercase_ascii subtype in 169 + let params, quality, i = parse_params s i in 170 + Some ({ type_; subtype; quality; params }, i) 171 + end 172 + 173 + (** Parse Accept header into list of media types, sorted by quality (highest 174 + first). 175 + 176 + Handles: 177 + - Multiple types separated by commas 178 + - Quality values (q=0.8) 179 + - Parameters (charset=utf-8) 180 + - Wildcards (* / * and type/ *) 181 + 182 + @param header Accept header value 183 + @return List of media types sorted by quality descending *) 184 + let parse_accept (header : string) : media_type list = 185 + let len = String.length header in 186 + let rec loop i acc = 187 + if i >= len then acc 188 + else 189 + match parse_media_type header i with 190 + | None -> 191 + (* Skip to next comma *) 192 + let rec skip j = 193 + if j >= len || header.[j] = ',' then j + 1 else skip (j + 1) 194 + in 195 + loop (skip i) acc 196 + | Some (mt, i) -> 197 + let i = skip_ws header i in 198 + let i = if i < len && header.[i] = ',' then i + 1 else i in 199 + loop i (mt :: acc) 200 + in 201 + let types = loop 0 [] in 202 + (* Sort by quality descending, then by specificity (more specific first) *) 203 + List.sort 204 + (fun a b -> 205 + let cmp = Float.compare b.quality a.quality in 206 + if cmp <> 0 then cmp 207 + else 208 + (* More specific types have higher priority *) 209 + let specificity mt = 210 + if mt.type_ = "*" then 0 211 + else if mt.subtype = "*" then 1 212 + else 2 + List.length mt.params 213 + in 214 + Int.compare (specificity b) (specificity a)) 215 + types 216 + 217 + (** {1 Negotiation} *) 218 + 219 + (** Check if a media type matches a format *) 220 + let media_type_matches (mt : media_type) (format : format) : bool = 221 + if mt.type_ = "*" && mt.subtype = "*" then true 222 + else 223 + let mime = mime_type_of_format format in 224 + match String.split_on_char '/' mime with 225 + | [ t; s ] -> 226 + (mt.type_ = "*" || mt.type_ = t) && (mt.subtype = "*" || mt.subtype = s) 227 + | _ -> false 228 + 229 + (** Negotiate best format from Accept header. 230 + 231 + @param accept Accept header value 232 + @param available List of formats the server can produce 233 + @return Best matching format or None if no match *) 234 + let negotiate ~accept ~available : format option = 235 + if available = [] then None 236 + else if accept = "" || accept = "*/*" then Some (List.hd available) 237 + else 238 + let types = parse_accept accept in 239 + (* Find first accepted type that matches an available format *) 240 + let rec find_match types = 241 + match types with 242 + | [] -> None 243 + | mt :: rest -> ( 244 + if mt.quality <= 0.0 then find_match rest 245 + else 246 + (* Find first available format matching this media type *) 247 + let matching = 248 + List.find_opt (fun fmt -> media_type_matches mt fmt) available 249 + in 250 + match matching with 251 + | Some _ as result -> result 252 + | None -> find_match rest) 253 + in 254 + find_match types 255 + 256 + (** Negotiate best format, raising exception if no match. 257 + 258 + @param accept Accept header value 259 + @param available List of formats the server can produce 260 + @return Best matching format 261 + @raise Not_acceptable_exn if no format matches *) 262 + let negotiate_exn ~accept ~available : format = 263 + match negotiate ~accept ~available with 264 + | Some fmt -> fmt 265 + | None -> raise Not_acceptable_exn 266 + 267 + (** {1 Request Format Storage} *) 268 + 269 + (** Key for storing negotiated format in request. We store format as a header 270 + since requests are immutable records. *) 271 + let format_header = "x-hcs-negotiated-format" 272 + 273 + (** Get negotiated format from request (set by Negotiate plug) *) 274 + let get_format (req : Server.request) : format option = 275 + match 276 + List.find_opt 277 + (fun (n, _) -> String.lowercase_ascii n = format_header) 278 + req.headers 279 + with 280 + | None -> None 281 + | Some (_, v) -> ( 282 + match v with 283 + | "json" -> Some Json 284 + | "html" -> Some Html 285 + | "text" -> Some Text 286 + | "xml" -> Some Xml 287 + | "csv" -> Some Csv 288 + | s -> Some (Custom s)) 289 + 290 + (** Set negotiated format on request *) 291 + let set_format (req : Server.request) (fmt : format) : Server.request = 292 + let headers = 293 + (format_header, format_name fmt) 294 + :: List.filter 295 + (fun (n, _) -> String.lowercase_ascii n <> format_header) 296 + req.headers 297 + in 298 + { req with headers } 299 + 300 + (** {1 Response Helpers} *) 301 + 302 + (** Create response with correct Content-Type for format *) 303 + let respond_format (fmt : format) ~(body : string) : Server.response = 304 + let content_type = 305 + match fmt with 306 + | Json -> "application/json; charset=utf-8" 307 + | Html -> "text/html; charset=utf-8" 308 + | Text -> "text/plain; charset=utf-8" 309 + | Xml -> "application/xml; charset=utf-8" 310 + | Csv -> "text/csv; charset=utf-8" 311 + | Custom s -> s 312 + in 313 + { 314 + status = `OK; 315 + headers = 316 + [ 317 + ("Content-Type", content_type); 318 + ("Content-Length", string_of_int (String.length body)); 319 + ]; 320 + body = Server.Body_string body; 321 + } 322 + 323 + (** Respond based on negotiated format with lazy body generation. Only evaluates 324 + the body function for the selected format. 325 + 326 + @param req Request with negotiated format 327 + @param json Function to generate JSON body 328 + @param html Function to generate HTML body 329 + @param text Optional function for plain text (defaults to json) 330 + @param xml Optional function for XML 331 + @return Response with appropriate body and Content-Type *) 332 + let respond (req : Server.request) ?json ?html ?text ?xml () : Server.response = 333 + let fmt = get_format req in 334 + match fmt with 335 + | Some Json -> ( 336 + match json with 337 + | Some f -> respond_format Json ~body:(f ()) 338 + | None -> respond_format Text ~body:"") 339 + | Some Html -> ( 340 + match html with 341 + | Some f -> respond_format Html ~body:(f ()) 342 + | None -> ( 343 + match json with 344 + | Some f -> respond_format Json ~body:(f ()) 345 + | None -> respond_format Text ~body:"")) 346 + | Some Text -> ( 347 + match text with 348 + | Some f -> respond_format Text ~body:(f ()) 349 + | None -> ( 350 + match json with 351 + | Some f -> respond_format Json ~body:(f ()) 352 + | None -> respond_format Text ~body:"")) 353 + | Some Xml -> ( 354 + match xml with 355 + | Some f -> respond_format Xml ~body:(f ()) 356 + | None -> respond_format Text ~body:"") 357 + | Some Csv -> respond_format Csv ~body:"" 358 + | Some (Custom _) | None -> ( 359 + (* Default to first available *) 360 + match json with 361 + | Some f -> respond_format Json ~body:(f ()) 362 + | None -> ( 363 + match html with 364 + | Some f -> respond_format Html ~body:(f ()) 365 + | None -> respond_format Text ~body:"")) 366 + 367 + (** {1 Plug} *) 368 + 369 + (** Create content negotiation plug. 370 + 371 + Parses the Accept header and determines the best response format. If no 372 + acceptable format is found, returns 406 Not Acceptable. 373 + 374 + @param formats List of formats this endpoint can produce 375 + @return Plug that sets negotiated format on request *) 376 + let create ~(formats : format list) () : Core.t = 377 + fun handler req -> 378 + let accept = 379 + List.find_opt 380 + (fun (n, _) -> String.lowercase_ascii n = "accept") 381 + req.headers 382 + |> Option.map snd 383 + |> Option.value ~default:"*/*" 384 + in 385 + match negotiate ~accept ~available:formats with 386 + | None -> 387 + (* 406 Not Acceptable *) 388 + let acceptable = 389 + String.concat ", " (List.map mime_type_of_format formats) 390 + in 391 + { 392 + status = `Not_acceptable; 393 + headers = 394 + [ 395 + ("Content-Type", "text/plain; charset=utf-8"); ("Accept", acceptable); 396 + ]; 397 + body = Server.Body_string "Not Acceptable"; 398 + } 399 + | Some fmt -> 400 + let req = set_format req fmt in 401 + handler req
+223
lib/plug/session.ml
··· 1 + (** Cookie-based session management with fiber-local isolation. 2 + 3 + {[ 4 + let store = Session.Memory_store.create () in 5 + let session_plug = Session.create ~store () in 6 + 7 + let handler req = 8 + match Session.get "user_id" with 9 + | Some uid -> Session.put "last_seen" (string_of_float now); ... 10 + | None -> ... 11 + ]} *) 12 + 13 + type session = { 14 + id : string; 15 + data : (string * string) list; 16 + created_at : float; 17 + } 18 + 19 + type store = 20 + | Memory of (string, session) Kcas_data.Hashtbl.t 21 + | Cookie of { secret : string; max_age : float } 22 + 23 + type session_ctx = { 24 + mutable data : (string * string) list; 25 + mutable modified : bool; 26 + } 27 + 28 + let session_key : session_ctx Eio.Fiber.key = Eio.Fiber.create_key () 29 + 30 + let get_ctx () = 31 + match Eio.Fiber.get session_key with 32 + | Some ctx -> ctx 33 + | None -> failwith "Session: not in session scope (missing Session plug)" 34 + 35 + module Memory_store = struct 36 + let create () = Memory (Kcas_data.Hashtbl.create ()) 37 + 38 + let cleanup ?(max_age = 86400.0) store = 39 + match store with 40 + | Memory tbl -> 41 + let now = Unix.gettimeofday () in 42 + Kcas_data.Hashtbl.filter_map_inplace 43 + (fun _ sess -> 44 + if now -. sess.created_at > max_age then None else Some sess) 45 + tbl 46 + | Cookie _ -> () 47 + end 48 + 49 + module Cookie_store = struct 50 + let create ~secret ?(max_age = 86400.0) () = Cookie { secret; max_age } 51 + 52 + let serialize data = 53 + List.map (fun (k, v) -> Token.b64_encode k ^ "=" ^ Token.b64_encode v) data 54 + |> String.concat "&" 55 + 56 + let deserialize s = 57 + if s = "" then [] 58 + else 59 + String.split_on_char '&' s 60 + |> List.filter_map (fun pair -> 61 + match String.index_opt pair '=' with 62 + | Some i -> ( 63 + let k_b64 = String.sub pair 0 i in 64 + let v_b64 = 65 + String.sub pair (i + 1) (String.length pair - i - 1) 66 + in 67 + match (Token.b64_decode k_b64, Token.b64_decode v_b64) with 68 + | Ok k, Ok v -> Some (k, v) 69 + | _ -> None) 70 + | None -> None) 71 + 72 + let encrypt ~secret ~max_age data = 73 + Token.encrypt ~secret ~data:(serialize data) ~max_age 74 + 75 + let decrypt ~secret ~max_age token = 76 + match Token.decrypt ~secret ~token ~max_age with 77 + | Ok data -> Some (deserialize data) 78 + | Error _ -> None 79 + end 80 + 81 + let get key = 82 + let ctx = get_ctx () in 83 + List.assoc_opt key ctx.data 84 + 85 + let get_with_req _req key = get key 86 + 87 + let put key value = 88 + let ctx = get_ctx () in 89 + ctx.modified <- true; 90 + ctx.data <- (key, value) :: List.filter (fun (k, _) -> k <> key) ctx.data 91 + 92 + let put_with_req _req key value = put key value 93 + 94 + let delete key = 95 + let ctx = get_ctx () in 96 + ctx.modified <- true; 97 + ctx.data <- List.filter (fun (k, _) -> k <> key) ctx.data 98 + 99 + let delete_with_req _req key = delete key 100 + 101 + let clear () = 102 + let ctx = get_ctx () in 103 + ctx.modified <- true; 104 + ctx.data <- [] 105 + 106 + let clear_with_req _req = clear () 107 + 108 + let get_all () = 109 + let ctx = get_ctx () in 110 + ctx.data 111 + 112 + let get_all_with_req _req = get_all () 113 + 114 + type config = { 115 + cookie_name : string; 116 + store : store; 117 + secure : bool; 118 + same_site : [ `Strict | `Lax | `None ]; 119 + http_only : bool; 120 + path : string; 121 + max_age : float; 122 + } 123 + 124 + let parse_cookie ~cookie_name headers = 125 + match 126 + List.find_opt (fun (n, _) -> String.lowercase_ascii n = "cookie") headers 127 + with 128 + | None -> None 129 + | Some (_, cookies) -> 130 + String.split_on_char ';' cookies 131 + |> List.find_map (fun pair -> 132 + let pair = String.trim pair in 133 + match String.index_opt pair '=' with 134 + | None -> None 135 + | Some i -> 136 + let name = String.sub pair 0 i in 137 + if name = cookie_name then 138 + Some (String.sub pair (i + 1) (String.length pair - i - 1)) 139 + else None) 140 + 141 + let make_cookie ~config value = 142 + let same_site = 143 + match config.same_site with 144 + | `Strict -> "; SameSite=Strict" 145 + | `Lax -> "; SameSite=Lax" 146 + | `None -> "; SameSite=None" 147 + in 148 + Printf.sprintf "%s=%s; Path=%s; Max-Age=%.0f%s%s%s" config.cookie_name value 149 + config.path config.max_age 150 + (if config.secure then "; Secure" else "") 151 + (if config.http_only then "; HttpOnly" else "") 152 + same_site 153 + 154 + let generate_id () = Token.b64_encode (Mirage_crypto_rng.generate 16) 155 + 156 + (** Create session plug with configurable storage and cookie options. *) 157 + let create ~store ?(cookie_name = "_session") ?(secure = true) 158 + ?(same_site = `Lax) ?(http_only = true) ?(path = "/") ?(max_age = 86400.0) 159 + () : Core.t = 160 + let config = 161 + { cookie_name; store; secure; same_site; http_only; path; max_age } 162 + in 163 + fun handler req -> 164 + let existing_id = 165 + parse_cookie ~cookie_name:config.cookie_name req.Server.headers 166 + in 167 + let initial_data = 168 + match config.store with 169 + | Memory tbl -> ( 170 + match existing_id with 171 + | Some id -> ( 172 + match Kcas_data.Hashtbl.find_opt tbl id with 173 + | Some sess -> sess.data 174 + | None -> []) 175 + | None -> []) 176 + | Cookie { secret; max_age } -> ( 177 + match existing_id with 178 + | Some token -> ( 179 + match Cookie_store.decrypt ~secret ~max_age token with 180 + | Some data -> data 181 + | None -> []) 182 + | None -> []) 183 + in 184 + let ctx = { data = initial_data; modified = false } in 185 + let resp = Eio.Fiber.with_binding session_key ctx (fun () -> handler req) in 186 + if not ctx.modified then resp 187 + else 188 + match config.store with 189 + | Memory tbl -> 190 + let session_id = 191 + match existing_id with 192 + | Some id when Kcas_data.Hashtbl.mem tbl id -> id 193 + | _ -> generate_id () 194 + in 195 + if ctx.data <> [] then begin 196 + Kcas_data.Hashtbl.replace tbl session_id 197 + { 198 + id = session_id; 199 + data = ctx.data; 200 + created_at = Unix.gettimeofday (); 201 + }; 202 + let cookie = make_cookie ~config session_id in 203 + { 204 + resp with 205 + Server.headers = ("Set-Cookie", cookie) :: resp.headers; 206 + } 207 + end 208 + else begin 209 + (match existing_id with 210 + | Some id -> Kcas_data.Hashtbl.remove tbl id 211 + | None -> ()); 212 + resp 213 + end 214 + | Cookie { secret; max_age } -> 215 + if ctx.data <> [] then begin 216 + let token = Cookie_store.encrypt ~secret ~max_age ctx.data in 217 + let cookie = make_cookie ~config token in 218 + { 219 + resp with 220 + Server.headers = ("Set-Cookie", cookie) :: resp.headers; 221 + } 222 + end 223 + else resp
+217
lib/plug/token.ml
··· 1 + (** Signed and encrypted tokens for authentication. 2 + 3 + Provides secure token generation and verification for auth tokens, password 4 + reset, email verification, etc. 5 + 6 + Token format: 7 + - signed: base64(data) . timestamp . base64(hmac) 8 + - encrypted: base64(nonce + ciphertext + tag) . timestamp . base64(hmac) *) 9 + 10 + (** Error types for token operations *) 11 + type error = 12 + | Expired (** Token has exceeded max_age *) 13 + | Invalid (** Token format invalid or signature mismatch *) 14 + | Tampered (** Token data has been modified *) 15 + 16 + let error_to_string = function 17 + | Expired -> "token expired" 18 + | Invalid -> "invalid token" 19 + | Tampered -> "token tampered" 20 + 21 + (** {1 Internal helpers} *) 22 + 23 + (** Get current Unix timestamp *) 24 + let now () = Unix.gettimeofday () 25 + 26 + (** Encode timestamp as compact varint-like format (8 bytes max) *) 27 + let encode_timestamp ts = 28 + let ts_int = Int64.of_float ts in 29 + let buf = Buffer.create 8 in 30 + let rec encode v = 31 + if Int64.compare v 0L = 0 then () 32 + else begin 33 + Buffer.add_char buf (Char.chr (Int64.to_int (Int64.logand v 0xFFL))); 34 + encode (Int64.shift_right_logical v 8) 35 + end 36 + in 37 + if Int64.compare ts_int 0L = 0 then Buffer.add_char buf '\000' 38 + else encode ts_int; 39 + Buffer.contents buf 40 + 41 + (** Decode timestamp from bytes *) 42 + let decode_timestamp s = 43 + let len = String.length s in 44 + let rec decode acc shift i = 45 + if i >= len then acc 46 + else 47 + let byte = Int64.of_int (Char.code s.[i]) in 48 + decode (Int64.logor acc (Int64.shift_left byte shift)) (shift + 8) (i + 1) 49 + in 50 + Int64.to_float (decode 0L 0 0) 51 + 52 + (** Constant-time string comparison to prevent timing attacks *) 53 + let secure_compare a b = 54 + let len_a = String.length a in 55 + let len_b = String.length b in 56 + if len_a <> len_b then false 57 + else 58 + let result = ref 0 in 59 + for i = 0 to len_a - 1 do 60 + result := !result lor (Char.code a.[i] lxor Char.code b.[i]) 61 + done; 62 + !result = 0 63 + 64 + (** Compute HMAC-SHA256 *) 65 + let hmac ~key data = 66 + Digestif.SHA256.hmac_string ~key data |> Digestif.SHA256.to_raw_string 67 + 68 + (** Base64 URL-safe encoding (no padding) *) 69 + let b64_encode s = 70 + Base64.encode_string ~pad:false ~alphabet:Base64.uri_safe_alphabet s 71 + 72 + (** Base64 URL-safe decoding *) 73 + let b64_decode s = 74 + (* Add padding if needed *) 75 + let len = String.length s in 76 + let padded = match len mod 4 with 2 -> s ^ "==" | 3 -> s ^ "=" | _ -> s in 77 + Base64.decode ~alphabet:Base64.uri_safe_alphabet padded 78 + 79 + (** {1 Signed tokens} *) 80 + 81 + (** Sign data with HMAC-SHA256. 82 + 83 + Format: base64(data).base64(timestamp).base64(hmac) 84 + 85 + The HMAC covers both data and timestamp to prevent replay attacks with 86 + modified timestamps. 87 + 88 + @param secret Shared secret key (should be at least 32 bytes) 89 + @param data Payload to sign (arbitrary string) 90 + @param max_age Maximum token age in seconds 91 + @return URL-safe signed token string *) 92 + let sign ~secret ~data ~max_age:_ = 93 + let ts = now () in 94 + let ts_bytes = encode_timestamp ts in 95 + let data_b64 = b64_encode data in 96 + let ts_b64 = b64_encode ts_bytes in 97 + (* HMAC covers data and timestamp *) 98 + let to_sign = data_b64 ^ "." ^ ts_b64 in 99 + let signature = hmac ~key:secret to_sign in 100 + let sig_b64 = b64_encode signature in 101 + data_b64 ^ "." ^ ts_b64 ^ "." ^ sig_b64 102 + 103 + (** Verify a signed token. 104 + 105 + @param secret Shared secret key (must match signing key) 106 + @param token The signed token to verify 107 + @param max_age Maximum allowed age in seconds 108 + @return Ok(data) if valid, Error(reason) otherwise *) 109 + let verify ~secret ~token ~max_age = 110 + match String.split_on_char '.' token with 111 + | [ data_b64; ts_b64; sig_b64 ] -> ( 112 + (* Decode signature and verify first (fail fast on tampering) *) 113 + match (b64_decode sig_b64, b64_decode data_b64, b64_decode ts_b64) with 114 + | Ok signature, Ok data, Ok ts_bytes -> 115 + let to_sign = data_b64 ^ "." ^ ts_b64 in 116 + let expected = hmac ~key:secret to_sign in 117 + if not (secure_compare expected signature) then Error Tampered 118 + else 119 + let ts = decode_timestamp ts_bytes in 120 + let age = now () -. ts in 121 + if age > max_age then Error Expired 122 + else if age < -60.0 then 123 + (* Allow 60s clock skew, but reject future tokens *) 124 + Error Invalid 125 + else Ok data 126 + | _ -> Error Invalid) 127 + | _ -> Error Invalid 128 + 129 + (** {1 Encrypted tokens} *) 130 + 131 + (** Encrypt and sign data using AES-256-GCM. 132 + 133 + Format: base64(nonce + ciphertext + tag).base64(timestamp).base64(hmac) 134 + 135 + Encryption uses AES-256-GCM for authenticated encryption. The HMAC provides 136 + an additional layer of authentication over the encrypted blob and timestamp. 137 + 138 + @param secret Shared secret key (first 32 bytes for AES, full key for HMAC) 139 + @param data Payload to encrypt 140 + @param max_age Maximum token age in seconds 141 + @return URL-safe encrypted token string *) 142 + let encrypt ~secret ~data ~max_age:_ = 143 + let ts = now () in 144 + let ts_bytes = encode_timestamp ts in 145 + let aes_key = 146 + if String.length secret >= 32 then String.sub secret 0 32 147 + else Digestif.SHA256.(digest_string secret |> to_raw_string) 148 + in 149 + let nonce = Mirage_crypto_rng.generate 12 in 150 + let key = Mirage_crypto.AES.GCM.of_secret aes_key in 151 + let ciphertext = 152 + Mirage_crypto.AES.GCM.authenticate_encrypt ~key ~nonce data 153 + in 154 + let encrypted = nonce ^ ciphertext in 155 + let encrypted_b64 = b64_encode encrypted in 156 + let ts_b64 = b64_encode ts_bytes in 157 + let to_sign = encrypted_b64 ^ "." ^ ts_b64 in 158 + let signature = hmac ~key:secret to_sign in 159 + let sig_b64 = b64_encode signature in 160 + encrypted_b64 ^ "." ^ ts_b64 ^ "." ^ sig_b64 161 + 162 + (** Decrypt and verify an encrypted token. 163 + 164 + @param secret Shared secret key (must match encryption key) 165 + @param token The encrypted token to decrypt 166 + @param max_age Maximum allowed age in seconds 167 + @return Ok(data) if valid, Error(reason) otherwise *) 168 + let decrypt ~secret ~token ~max_age = 169 + match String.split_on_char '.' token with 170 + | [ encrypted_b64; ts_b64; sig_b64 ] -> ( 171 + match 172 + (b64_decode sig_b64, b64_decode encrypted_b64, b64_decode ts_b64) 173 + with 174 + | Ok signature, Ok encrypted, Ok ts_bytes -> ( 175 + let to_sign = encrypted_b64 ^ "." ^ ts_b64 in 176 + let expected = hmac ~key:secret to_sign in 177 + if not (secure_compare expected signature) then Error Tampered 178 + else 179 + let ts = decode_timestamp ts_bytes in 180 + let age = now () -. ts in 181 + if age > max_age then Error Expired 182 + else if age < -60.0 then Error Invalid 183 + else if String.length encrypted < 28 then Error Invalid 184 + else 185 + let nonce = String.sub encrypted 0 12 in 186 + let ciphertext = 187 + String.sub encrypted 12 (String.length encrypted - 12) 188 + in 189 + let aes_key = 190 + if String.length secret >= 32 then String.sub secret 0 32 191 + else Digestif.SHA256.(digest_string secret |> to_raw_string) 192 + in 193 + let key = Mirage_crypto.AES.GCM.of_secret aes_key in 194 + match 195 + Mirage_crypto.AES.GCM.authenticate_decrypt ~key ~nonce 196 + ciphertext 197 + with 198 + | Some plaintext -> Ok plaintext 199 + | None -> Error Tampered) 200 + | _ -> Error Invalid) 201 + | _ -> Error Invalid 202 + 203 + (** {1 Convenience functions} *) 204 + 205 + (** Create a signed auth token with user data. Default max_age is 24 hours 206 + (86400 seconds). *) 207 + let auth_token ~secret ~user_id = sign ~secret ~data:user_id ~max_age:86400.0 208 + 209 + (** Create an encrypted password reset token. Default max_age is 1 hour (3600 210 + seconds). Encrypted so user cannot see/modify the payload. *) 211 + let reset_token ~secret ~user_id = encrypt ~secret ~data:user_id ~max_age:3600.0 212 + 213 + (** Verify an auth token, returning the user_id if valid. *) 214 + let verify_auth ~secret ~token ~max_age = verify ~secret ~token ~max_age 215 + 216 + (** Verify a reset token, returning the user_id if valid. *) 217 + let verify_reset ~secret ~token ~max_age = decrypt ~secret ~token ~max_age
+42
lib/pubsub.ml
··· 1 + type topic = string 2 + type message = string 3 + type subscription = { id : int; topic : topic; callback : message -> unit } 4 + 5 + type t = { 6 + topics : (topic, subscription list) Kcas_data.Hashtbl.t; 7 + next_id : int Atomic.t; 8 + } 9 + 10 + let create () = 11 + { topics = Kcas_data.Hashtbl.create (); next_id = Atomic.make 0 } 12 + 13 + let subscribe t topic callback = 14 + let id = Atomic.fetch_and_add t.next_id 1 in 15 + let sub = { id; topic; callback } in 16 + let subs = 17 + match Kcas_data.Hashtbl.find_opt t.topics topic with 18 + | Some existing -> sub :: existing 19 + | None -> [ sub ] 20 + in 21 + Kcas_data.Hashtbl.replace t.topics topic subs; 22 + sub 23 + 24 + let unsubscribe t sub = 25 + match Kcas_data.Hashtbl.find_opt t.topics sub.topic with 26 + | None -> () 27 + | Some subs -> 28 + let remaining = List.filter (fun s -> s.id <> sub.id) subs in 29 + if remaining = [] then Kcas_data.Hashtbl.remove t.topics sub.topic 30 + else Kcas_data.Hashtbl.replace t.topics sub.topic remaining 31 + 32 + let broadcast t topic msg = 33 + match Kcas_data.Hashtbl.find_opt t.topics topic with 34 + | None -> () 35 + | Some subs -> List.iter (fun sub -> sub.callback msg) subs 36 + 37 + let subscriber_count t topic = 38 + match Kcas_data.Hashtbl.find_opt t.topics topic with 39 + | None -> 0 40 + | Some subs -> List.length subs 41 + 42 + let topics t = Kcas_data.Hashtbl.to_seq_keys t.topics |> List.of_seq