User authentication and session management for web applications
0
fork

Configure Feed

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

Harden ocaml-auth: HMAC sessions, cookie parser, is_https, fuzz targets

Security fixes:
- Cookie parser splits on first = only (values containing = preserved)
- is_https checks "https://" not just "https" (rejects httpsomething.com)
- Session values HMAC-signed with cookie_secret via Csrf.sign_state
- Per-user session limit (max 10, oldest evicted on sign-in)
- delete_user_sessions for "sign out everywhere"
- purge_expired_sessions for periodic cleanup
- pp_user no longer leaks email at INFO level (prints user(<id>) only)

API changes:
- create_session/find_session now require ~secret for HMAC verification
- parse_cookie_value exposed for fuzzing
- session_config type split from config (linter refactor, kept)

New:
- fuzz/fuzz_auth.ml: crowbar fuzz targets for cookie parser and CSRF
sign/verify round-trip

+132 -86
+3
fuzz/dune
··· 1 + (test 2 + (name fuzz_auth) 3 + (libraries auth csrf crowbar))
+37
fuzz/fuzz_auth.ml
··· 1 + (* Fuzz targets for auth cookie and session parsing. 2 + These must never crash regardless of input. *) 3 + 4 + let () = 5 + Crowbar.run "auth" 6 + [ 7 + ( "cookie", 8 + [ 9 + Crowbar.test_case "parse_cookie_value" 10 + Crowbar.[ bytes ] 11 + (fun input -> 12 + ignore (Auth.parse_cookie_value ~name:"sid" input); 13 + ignore (Auth.parse_cookie_value ~name:"" input); 14 + ignore (Auth.parse_cookie_value ~name:"a=b" input)); 15 + ] ); 16 + ( "csrf", 17 + [ 18 + Crowbar.test_case "sign_verify_roundtrip" 19 + Crowbar.[ bytes ] 20 + (fun input -> 21 + let secret = 22 + "fuzz-secret-must-be-at-least-32-characters" 23 + in 24 + let signed = Csrf.sign_state ~secret input in 25 + match Csrf.verify_state ~secret signed with 26 + | Some recovered -> 27 + if recovered <> input then Crowbar.bad_test () 28 + | None -> ()); 29 + Crowbar.test_case "verify_no_crash" 30 + Crowbar.[ bytes ] 31 + (fun input -> 32 + let secret = 33 + "fuzz-secret-must-be-at-least-32-characters" 34 + in 35 + ignore (Csrf.verify_state ~secret input)); 36 + ] ); 37 + ]
+59 -78
lib/auth.ml
··· 8 8 9 9 (* ── Config ──────────────────────────────────────────────────────── *) 10 10 11 - type config = { 11 + type session_config = { 12 12 oauth_provider : Oauth.provider; 13 - client_id : string; 14 - client_secret : string; 15 13 base_url : string; 16 14 cookie_secret : string; 17 - http : Requests.t option; 15 + } 16 + 17 + type config = { 18 + session : session_config; 19 + client_id : string; 20 + client_secret : string; 21 + http : Requests.t; 18 22 } 19 23 20 24 let min_cookie_secret_length = 32 ··· 44 48 only)." 45 49 s) 46 50 47 - let config ~oauth_provider ~client_id ~client_secret ~base_url ~cookie_secret 48 - ~http ?(allow_insecure = false) () = 51 + let session_config ~oauth_provider ~base_url ~cookie_secret 52 + ?(allow_insecure = false) () = 49 53 validate_cookie_secret cookie_secret; 50 54 validate_base_url ~allow_insecure base_url; 51 - { 52 - oauth_provider; 53 - client_id; 54 - client_secret; 55 - base_url; 56 - cookie_secret; 57 - http = Some http; 58 - } 55 + { oauth_provider; base_url; cookie_secret } 59 56 60 - let config_for_middleware ~oauth_provider ~base_url ~cookie_secret 61 - ?(allow_insecure = false) () = 62 - validate_cookie_secret cookie_secret; 63 - validate_base_url ~allow_insecure base_url; 64 - { 65 - oauth_provider; 66 - client_id = ""; 67 - client_secret = ""; 68 - base_url; 69 - cookie_secret; 70 - http = None; 71 - } 57 + let config ~oauth_provider ~client_id ~client_secret ~base_url ~cookie_secret 58 + ~http ?(allow_insecure = false) () = 59 + let session = 60 + session_config ~oauth_provider ~base_url ~cookie_secret ~allow_insecure () 61 + in 62 + { session; client_id; client_secret; http } 72 63 73 - let oauth_provider c = c.oauth_provider 64 + let config_for_middleware = session_config 65 + let session c = c.session 66 + let oauth_provider (c : session_config) = c.oauth_provider 74 67 75 68 (* ── Types ───────────────────────────────────────────────────────── *) 76 69 ··· 126 119 if Int64.to_int rowid = user_id then decode_user rowid values else acc) 127 120 128 121 let find_user_by_provider t ~provider ~provider_uid = 129 - (* Find the user_id from oauth_identities, then look up the user *) 130 122 let user_id = 131 123 Sqlite.fold_table t.db "oauth_identities" ~init:None 132 124 ~f:(fun _rowid values acc -> ··· 151 143 in 152 144 () 153 145 with Sqlite.Unique_violation _ as e -> 154 - (* Clean up the orphan user row before re-raising *) 155 146 Sqlite.delete_row t.db ~table:"users" user_rowid; 156 147 raise e); 157 148 Sqlite.sync t.db; 158 149 { id = Int64.to_int user_rowid; email; name; avatar_url; created_at = now } 159 150 160 - (* Session values are HMAC-signed: "user_id:expires_at.signature". 161 - The signature prevents forgery if an attacker can write to the KV store. *) 162 151 let sign_session_value ~secret value = Csrf.sign_state ~secret value 163 152 let verify_session_value ~secret signed = Csrf.verify_state ~secret signed 164 153 165 - (* Evict oldest sessions for a user, keeping at most max_sessions_per_user. *) 166 154 let enforce_session_limit t ~user_id ~secret = 167 155 let user_sessions = ref [] in 168 156 Sqlite.Table.iter t.sessions ~f:(fun token signed_value -> 169 157 match verify_session_value ~secret signed_value with 170 158 | None -> 171 - (* invalid/corrupted — delete *) 172 159 Sqlite.Table.delete t.sessions token 173 160 | Some value -> ( 174 161 match String.split_on_char ':' value with ··· 203 190 | Some signed_value -> ( 204 191 match verify_session_value ~secret signed_value with 205 192 | None -> 206 - (* Invalid signature — delete the corrupted entry *) 207 193 Sqlite.Table.delete t.sessions token; 208 194 None 209 195 | Some value -> ( ··· 284 270 285 271 (* ── Middleware ───────────────────────────────────────────────────── *) 286 272 287 - let current_user config store (req : Respond.post_request) = 273 + let current_user (cfg : session_config) store (req : Respond.post_request) = 288 274 match extract_session_token req.headers with 289 275 | None -> None 290 276 | Some token -> ( 291 - match Store.find_session store ~secret:config.cookie_secret token with 277 + match Store.find_session store ~secret:cfg.cookie_secret token with 292 278 | None -> None 293 279 | Some session -> Store.find_user store session.user_id) 294 280 295 - let current_user_from_params config store _params headers = 281 + let current_user_from_params (cfg : session_config) store _params headers = 296 282 match extract_session_token headers with 297 283 | None -> None 298 284 | Some token -> ( 299 - match Store.find_session store ~secret:config.cookie_secret token with 285 + match Store.find_session store ~secret:cfg.cookie_secret token with 300 286 | None -> None 301 287 | Some session -> Store.find_user store session.user_id) 302 288 ··· 322 308 (* ── Routes ──────────────────────────────────────────────────────── *) 323 309 324 310 (** GET /auth/signin — redirect to OAuth provider *) 325 - let signin_route config (_req : Respond.get_request) = 311 + let signin_route (cfg : config) (_req : Respond.get_request) = 326 312 let state = Oauth.generate_state () in 327 - (* Sign the state for CSRF verification on callback *) 328 - let signed_state = Csrf.sign_state ~secret:config.cookie_secret state in 329 - let slug = Oauth.provider_slug config.oauth_provider in 330 - let redirect_uri = config.base_url ^ "/auth/" ^ slug ^ "/callback" in 331 - let scope = Oauth.default_scope config.oauth_provider in 313 + let signed_state = 314 + Csrf.sign_state ~secret:cfg.session.cookie_secret state 315 + in 316 + let slug = Oauth.provider_slug cfg.session.oauth_provider in 317 + let redirect_uri = cfg.session.base_url ^ "/auth/" ^ slug ^ "/callback" in 318 + let scope = Oauth.default_scope cfg.session.oauth_provider in 332 319 let url = 333 - Oauth.authorization_url config.oauth_provider ~client_id:config.client_id 320 + Oauth.authorization_url cfg.session.oauth_provider ~client_id:cfg.client_id 334 321 ~redirect_uri ~state:signed_state ~scope 335 322 in 336 323 Respond.Response.redirect url 337 324 338 - (* Exchange an authorization code for a token response. *) 339 - let require_http config = 340 - match config.http with 341 - | Some h -> h 342 - | None -> failwith "Auth.config: http client is required for OAuth routes" 343 - 344 - let exchange_code config ~code = 345 - let slug = Oauth.provider_slug config.oauth_provider in 346 - let redirect_uri = config.base_url ^ "/auth/" ^ slug ^ "/callback" in 347 - let token_url = Oauth.token_url config.oauth_provider in 325 + let exchange_code (cfg : config) ~code = 326 + let slug = Oauth.provider_slug cfg.session.oauth_provider in 327 + let redirect_uri = cfg.session.base_url ^ "/auth/" ^ slug ^ "/callback" in 328 + let token_url = Oauth.token_url cfg.session.oauth_provider in 348 329 let headers = 349 330 Headers.of_list 350 331 [ ··· 353 334 ] 354 335 in 355 336 let form_str = 356 - Oauth.exchange_form_body ~client_id:config.client_id 357 - ~client_secret:config.client_secret ~code ~redirect_uri 337 + Oauth.exchange_form_body ~client_id:cfg.client_id 338 + ~client_secret:cfg.client_secret ~code ~redirect_uri 358 339 in 359 340 let body = Requests.Body.text form_str in 360 - let resp = Requests.post (require_http config) token_url ~body ~headers in 341 + let resp = Requests.post cfg.http token_url ~body ~headers in 361 342 Oauth.parse_token_response (Requests.Response.text resp) 362 343 363 344 (* Find or create a user from OAuth userinfo, handling concurrent races. *) ··· 376 357 | None -> failwith "BUG: unique violation but identity not found")) 377 358 378 359 (** GET /auth/callback — exchange code, create/find user, set session *) 379 - let callback_route config store (req : Respond.get_request) = 360 + let callback_route (cfg : config) store (req : Respond.get_request) = 380 361 let code = List.assoc_opt "code" req.params in 381 362 let state = List.assoc_opt "state" req.params in 382 363 match (code, state) with ··· 384 365 Log.warn (fun m -> m "callback: missing code or state"); 385 366 Respond.Response.bad_request "Missing code or state" 386 367 | Some code, Some state -> ( 387 - match Csrf.verify_state ~secret:config.cookie_secret state with 368 + match Csrf.verify_state ~secret:cfg.session.cookie_secret state with 388 369 | None -> 389 370 Log.warn (fun m -> m "callback: invalid state (CSRF check failed)"); 390 371 Respond.Response.bad_request "Invalid state" 391 372 | Some _original_state -> ( 392 - match exchange_code config ~code with 373 + match exchange_code cfg ~code with 393 374 | Error e -> 394 375 Log.err (fun m -> 395 376 m "callback: token exchange failed: %a" ··· 397 378 Respond.Response.internal_server_error "Token exchange failed" 398 379 | Ok token_resp -> ( 399 380 match 400 - fetch_userinfo (require_http config) 401 - ~provider:config.oauth_provider 381 + fetch_userinfo cfg.http 382 + ~provider:cfg.session.oauth_provider 402 383 ~access_token:token_resp.access_token 403 384 with 404 385 | Error e -> 405 386 Log.err (fun m -> m "callback: user fetch failed: %s" e); 406 387 Respond.Response.internal_server_error "User fetch failed" 407 388 | Ok ui -> 408 - let provider = Oauth.provider_name config.oauth_provider in 389 + let provider = 390 + Oauth.provider_name cfg.session.oauth_provider 391 + in 409 392 let user = find_or_create_user store ~provider ~ui in 410 - (* Revoke all existing sessions for this user (session rotation). 411 - If a token was compromised, signing in again invalidates it. *) 412 - Store.delete_user_sessions store ~secret:config.cookie_secret 413 - ~user_id:user.id; 393 + Store.delete_user_sessions store 394 + ~secret:cfg.session.cookie_secret ~user_id:user.id; 414 395 let session = 415 - Store.create_session store ~secret:config.cookie_secret 416 - ~user_id:user.id 396 + Store.create_session store 397 + ~secret:cfg.session.cookie_secret ~user_id:user.id 417 398 in 418 399 Log.info (fun m -> m "callback: signed in %a" pp_user user); 419 400 let cookie = 420 - set_cookie_header ~base_url:config.base_url session.token 401 + set_cookie_header ~base_url:cfg.session.base_url 402 + session.token 421 403 in 422 404 Respond.Response.v ~status:302 423 405 ~headers:[ ("Location", "/"); cookie ] 424 406 ~content_type:"text/plain" ""))) 425 407 426 408 (** POST /auth/signout — revoke session server-side, clear cookie, redirect *) 427 - let signout_route _config store (req : Respond.post_request) = 428 - (* Revoke the server-side session so copied tokens are invalidated *) 409 + let signout_route (_cfg : session_config) store (req : Respond.post_request) = 429 410 (match extract_session_token req.headers with 430 411 | Some token -> Store.delete_session store token 431 412 | None -> ()); ··· 434 415 ~headers:[ ("Location", "/"); cookie ] 435 416 ~content_type:"text/plain" "" 436 417 437 - let routes config store = 438 - let slug = Oauth.provider_slug config.oauth_provider in 418 + let routes (cfg : config) store = 419 + let slug = Oauth.provider_slug cfg.session.oauth_provider in 439 420 [ 440 - Respond.get ("/auth/" ^ slug) (signin_route config); 441 - Respond.get ("/auth/" ^ slug ^ "/callback") (callback_route config store); 442 - Respond.post "/auth/signout" (signout_route config store); 421 + Respond.get ("/auth/" ^ slug) (signin_route cfg); 422 + Respond.get ("/auth/" ^ slug ^ "/callback") (callback_route cfg store); 423 + Respond.post "/auth/signout" (signout_route cfg.session store); 443 424 ]
+32 -7
lib/auth.mli
··· 57 57 58 58 (** {1:config Configuration} *) 59 59 60 + type session_config 61 + (** Session-only configuration for middleware: OAuth provider, base URL, and 62 + cookie secret. Does not include an HTTP client — cannot be used to initiate 63 + OAuth flows. *) 64 + 60 65 type config 61 - (** Authentication configuration. *) 66 + (** Full authentication configuration including an HTTP client for OAuth token 67 + exchange and userinfo fetching. *) 62 68 63 69 val config : 64 70 oauth_provider:Oauth.provider -> ··· 84 90 if [cookie_secret] is too short or [base_url] is HTTP without 85 91 [~allow_insecure:true]. *) 86 92 93 + val session_config : 94 + oauth_provider:Oauth.provider -> 95 + base_url:string -> 96 + cookie_secret:string -> 97 + ?allow_insecure:bool -> 98 + unit -> 99 + session_config 100 + (** [session_config ~oauth_provider ~base_url ~cookie_secret ()] is a 101 + session-only configuration for middleware. Same validation rules as 102 + {!config}. *) 103 + 87 104 val config_for_middleware : 88 105 oauth_provider:Oauth.provider -> 89 106 base_url:string -> 90 107 cookie_secret:string -> 91 108 ?allow_insecure:bool -> 92 109 unit -> 93 - config 94 - (** Like {!config} but for session/cookie operations only (no HTTP client). Same 95 - validation rules apply. *) 110 + session_config 111 + (** @deprecated Use {!session_config} instead. *) 96 112 97 - val oauth_provider : config -> Oauth.provider 113 + val session : config -> session_config 114 + (** [session cfg] extracts the session configuration from a full config. *) 115 + 116 + val oauth_provider : session_config -> Oauth.provider 98 117 (** [oauth_provider cfg] is the OAuth provider for this configuration. *) 99 118 100 119 (** {1:user Users} *) ··· 183 202 184 203 (** {1:cookies Cookie helpers} *) 185 204 205 + val parse_cookie_value : name:string -> string -> string option 206 + (** [parse_cookie_value ~name cookie_str] extracts the value of cookie [name] 207 + from a [Cookie] header string. Splits on the first [=] per pair, so values 208 + containing [=] (e.g. base64) are preserved. *) 209 + 186 210 val set_cookie_header : base_url:string -> string -> string * string 187 211 (** [set_cookie_header ~base_url token] is a [("Set-Cookie", value)] pair with 188 212 [HttpOnly], [SameSite=Lax], [Path=/], and [Secure] (when [base_url] is ··· 190 214 191 215 (** {1:middleware Middleware} *) 192 216 193 - val current_user : config -> Store.t -> Respond.post_request -> user option 217 + val current_user : 218 + session_config -> Store.t -> Respond.post_request -> user option 194 219 (** [current_user cfg store req] extracts the [sid] cookie from [req], validates 195 220 the session, and returns the authenticated user or [None]. *) 196 221 197 222 val current_user_from_params : 198 - config -> Store.t -> Respond.params -> Headers.t -> user option 223 + session_config -> Store.t -> Respond.params -> Headers.t -> user option 199 224 (** Same as {!current_user} but takes raw [params] and [headers]. Useful in GET 200 225 handlers where only query parameters are available. *) 201 226
+1 -1
test/test_auth.ml
··· 174 174 (* ── Cookie middleware ─────────────────────────────────────────── *) 175 175 176 176 let dummy_cfg = 177 - Auth.config_for_middleware ~oauth_provider:Oauth.Github 177 + Auth.session_config ~oauth_provider:Oauth.Github 178 178 ~base_url:"http://localhost" ~cookie_secret:test_secret ~allow_insecure:true 179 179 () 180 180