Matrix protocol in OCaml, Eio specialised
at main 435 lines 15 kB view raw
1(** User-Interactive Authentication API (UIAA). 2 3 UIAA is Matrix's mechanism for protecting sensitive operations that require 4 additional verification beyond just an access token. Operations like: 5 - Deleting devices 6 - Changing passwords 7 - Adding 3PIDs 8 - Deactivating accounts 9 10 When these operations return a 401 with UIAA challenge, clients must 11 complete authentication stages before the operation can proceed. *) 12 13(** Authentication stage types *) 14type auth_type = 15 | Password 16 | Recaptcha 17 | OAuth2 18 | Email_identity 19 | Msisdn (* Phone number *) 20 | Dummy 21 | Registration_token 22 | Terms 23 | Sso 24 | Sso_fallback 25 | Custom of string 26 27let auth_type_of_string = function 28 | "m.login.password" -> Password 29 | "m.login.recaptcha" -> Recaptcha 30 | "m.login.oauth2" -> OAuth2 31 | "m.login.email.identity" -> Email_identity 32 | "m.login.msisdn" -> Msisdn 33 | "m.login.dummy" -> Dummy 34 | "m.login.registration_token" -> Registration_token 35 | "m.login.terms" -> Terms 36 | "m.login.sso" -> Sso 37 | "org.matrix.login.sso.fallback" -> Sso_fallback 38 | s -> Custom s 39 40let auth_type_to_string = function 41 | Password -> "m.login.password" 42 | Recaptcha -> "m.login.recaptcha" 43 | OAuth2 -> "m.login.oauth2" 44 | Email_identity -> "m.login.email.identity" 45 | Msisdn -> "m.login.msisdn" 46 | Dummy -> "m.login.dummy" 47 | Registration_token -> "m.login.registration_token" 48 | Terms -> "m.login.terms" 49 | Sso -> "m.login.sso" 50 | Sso_fallback -> "org.matrix.login.sso.fallback" 51 | Custom s -> s 52 53(** Authentication flow - a sequence of stages that must be completed *) 54type auth_flow = { 55 stages : auth_type list; 56} 57 58(* Internal type for JSON parsing *) 59type auth_flow_json = { 60 stages_json : string list; 61} 62 63let auth_flow_jsont = 64 let json_type = 65 Jsont.Object.( 66 map (fun stages_json -> { stages_json }) 67 |> mem "stages" (Jsont.list Jsont.string) ~dec_absent:[] 68 ~enc:(fun t -> t.stages_json) 69 |> finish) 70 in 71 Jsont.map 72 ~dec:(fun flow -> { stages = List.map auth_type_of_string flow.stages_json }) 73 ~enc:(fun flow -> { stages_json = List.map auth_type_to_string flow.stages }) 74 json_type 75 76(** UIAA response from server when authentication is required *) 77type uiaa_response = { 78 session : string option; 79 flows : auth_flow list; 80 completed : auth_type list; 81 params : Jsont.json option; 82 error : string option; 83 errcode : string option; 84} 85 86(* Internal type for JSON parsing *) 87type uiaa_response_json = { 88 session_json : string option; 89 flows_json : auth_flow list; 90 completed_json : string list; 91 params_json : Jsont.json option; 92 error_json : string option; 93 errcode_json : string option; 94} 95 96let uiaa_response_jsont = 97 let json_type = 98 Jsont.Object.( 99 map (fun session_json flows_json completed_json params_json error_json errcode_json -> 100 { session_json; flows_json; completed_json; params_json; error_json; errcode_json }) 101 |> opt_mem "session" Jsont.string ~enc:(fun t -> t.session_json) 102 |> mem "flows" (Jsont.list auth_flow_jsont) ~dec_absent:[] ~enc:(fun t -> t.flows_json) 103 |> mem "completed" (Jsont.list Jsont.string) ~dec_absent:[] 104 ~enc:(fun t -> t.completed_json) 105 |> opt_mem "params" Jsont.json ~enc:(fun t -> t.params_json) 106 |> opt_mem "error" Jsont.string ~enc:(fun t -> t.error_json) 107 |> opt_mem "errcode" Jsont.string ~enc:(fun t -> t.errcode_json) 108 |> finish) 109 in 110 Jsont.map 111 ~dec:(fun r -> { 112 session = r.session_json; 113 flows = r.flows_json; 114 completed = List.map auth_type_of_string r.completed_json; 115 params = r.params_json; 116 error = r.error_json; 117 errcode = r.errcode_json; 118 }) 119 ~enc:(fun r -> { 120 session_json = r.session; 121 flows_json = r.flows; 122 completed_json = List.map auth_type_to_string r.completed; 123 params_json = r.params; 124 error_json = r.error; 125 errcode_json = r.errcode; 126 }) 127 json_type 128 129(** Authentication data to send in response to UIAA challenge *) 130type auth_data = 131 | Password_auth of { 132 identifier : user_identifier; 133 password : string; 134 session : string option; 135 } 136 | Recaptcha_auth of { 137 response : string; 138 session : string option; 139 } 140 | Email_identity_auth of { 141 threepid_creds : threepid_creds; 142 session : string option; 143 } 144 | Msisdn_auth of { 145 threepid_creds : threepid_creds; 146 session : string option; 147 } 148 | Dummy_auth of { 149 session : string option; 150 } 151 | Token_auth of { 152 token : string; 153 session : string option; 154 } 155 | Terms_auth of { 156 session : string option; 157 } 158 159and user_identifier = 160 | User of string (* user_id *) 161 | ThirdParty of { medium : string; address : string } 162 | Phone of { country : string; phone : string } 163 164and threepid_creds = { 165 sid : string; 166 client_secret : string; 167 id_server : string option; 168 id_access_token : string option; 169} 170 171(** Encode user identifier to JSON *) 172let user_identifier_to_json = function 173 | User user_id -> 174 Printf.sprintf {|{"type":"m.id.user","user":"%s"}|} user_id 175 | ThirdParty { medium; address } -> 176 Printf.sprintf {|{"type":"m.id.thirdparty","medium":"%s","address":"%s"}|} 177 medium address 178 | Phone { country; phone } -> 179 Printf.sprintf {|{"type":"m.id.phone","country":"%s","phone":"%s"}|} 180 country phone 181 182(** Encode auth data to JSON object for request body *) 183let auth_data_to_json = function 184 | Password_auth { identifier; password; session } -> 185 let session_part = match session with 186 | Some s -> Printf.sprintf {|,"session":"%s"|} s 187 | None -> "" 188 in 189 Printf.sprintf {|{"type":"m.login.password","identifier":%s,"password":"%s"%s}|} 190 (user_identifier_to_json identifier) 191 password 192 session_part 193 | Recaptcha_auth { response; session } -> 194 let session_part = match session with 195 | Some s -> Printf.sprintf {|,"session":"%s"|} s 196 | None -> "" 197 in 198 Printf.sprintf {|{"type":"m.login.recaptcha","response":"%s"%s}|} 199 response session_part 200 | Email_identity_auth { threepid_creds = creds; session } -> 201 let session_part = match session with 202 | Some s -> Printf.sprintf {|,"session":"%s"|} s 203 | None -> "" 204 in 205 let id_server_part = match creds.id_server with 206 | Some s -> Printf.sprintf {|,"id_server":"%s"|} s 207 | None -> "" 208 in 209 let id_token_part = match creds.id_access_token with 210 | Some s -> Printf.sprintf {|,"id_access_token":"%s"|} s 211 | None -> "" 212 in 213 Printf.sprintf 214 {|{"type":"m.login.email.identity","threepid_creds":{"sid":"%s","client_secret":"%s"%s%s}%s}|} 215 creds.sid creds.client_secret id_server_part id_token_part session_part 216 | Msisdn_auth { threepid_creds = creds; session } -> 217 let session_part = match session with 218 | Some s -> Printf.sprintf {|,"session":"%s"|} s 219 | None -> "" 220 in 221 let id_server_part = match creds.id_server with 222 | Some s -> Printf.sprintf {|,"id_server":"%s"|} s 223 | None -> "" 224 in 225 let id_token_part = match creds.id_access_token with 226 | Some s -> Printf.sprintf {|,"id_access_token":"%s"|} s 227 | None -> "" 228 in 229 Printf.sprintf 230 {|{"type":"m.login.msisdn","threepid_creds":{"sid":"%s","client_secret":"%s"%s%s}%s}|} 231 creds.sid creds.client_secret id_server_part id_token_part session_part 232 | Dummy_auth { session } -> 233 let session_part = match session with 234 | Some s -> Printf.sprintf {|,"session":"%s"|} s 235 | None -> "" 236 in 237 Printf.sprintf {|{"type":"m.login.dummy"%s}|} session_part 238 | Token_auth { token; session } -> 239 let session_part = match session with 240 | Some s -> Printf.sprintf {|,"session":"%s"|} s 241 | None -> "" 242 in 243 Printf.sprintf {|{"type":"m.login.registration_token","token":"%s"%s}|} 244 token session_part 245 | Terms_auth { session } -> 246 let session_part = match session with 247 | Some s -> Printf.sprintf {|,"session":"%s"|} s 248 | None -> "" 249 in 250 Printf.sprintf {|{"type":"m.login.terms"%s}|} session_part 251 252(** Result of a UIAA operation *) 253type 'a uiaa_result = 254 | Uiaa_success of 'a 255 | Uiaa_auth_required of uiaa_response 256 | Uiaa_error of Error.t 257 258(** Check if a response is a UIAA challenge (401 with flows) *) 259let is_uiaa_response status_code body = 260 status_code = 401 && 261 String.length body > 0 && 262 (String.contains body 'f' && String.contains body 'l') (* Quick check for "flows" *) 263 264(** Parse a UIAA response from error body *) 265let parse_uiaa_response body = 266 match Jsont_bytesrw.decode_string uiaa_response_jsont body with 267 | Ok r -> Some r 268 | Error _ -> None 269 270(** Create password authentication data *) 271let password_auth ~user_id ~password ?session () = 272 Password_auth { 273 identifier = User user_id; 274 password; 275 session; 276 } 277 278(** Create dummy authentication (for flows that allow it) *) 279let dummy_auth ?session () = 280 Dummy_auth { session } 281 282(** Create recaptcha authentication *) 283let recaptcha_auth ~response ?session () = 284 Recaptcha_auth { response; session } 285 286(** Create email identity authentication *) 287let email_identity_auth ~sid ~client_secret ?id_server ?id_access_token ?session () = 288 Email_identity_auth { 289 threepid_creds = { sid; client_secret; id_server; id_access_token }; 290 session; 291 } 292 293(** Create registration token authentication *) 294let token_auth ~token ?session () = 295 Token_auth { token; session } 296 297(** Create terms acceptance authentication *) 298let terms_auth ?session () = 299 Terms_auth { session } 300 301(** Find the simplest flow to complete (fewest stages) *) 302let find_simplest_flow uiaa = 303 match uiaa.flows with 304 | [] -> None 305 | flows -> 306 Some (List.fold_left (fun acc flow -> 307 if List.length flow.stages < List.length acc.stages then flow else acc 308 ) (List.hd flows) flows) 309 310(** Check if a flow contains only the given auth types *) 311let flow_contains_only flow types = 312 List.for_all (fun stage -> List.mem stage types) flow.stages 313 314(** Check if password-only auth is available *) 315let has_password_only_flow uiaa = 316 List.exists (fun flow -> 317 flow_contains_only flow [Password] || 318 flow_contains_only flow [Password; Dummy] 319 ) uiaa.flows 320 321(** Check if dummy auth is available (often used in development) *) 322let has_dummy_flow uiaa = 323 List.exists (fun flow -> 324 flow_contains_only flow [Dummy] 325 ) uiaa.flows 326 327(** Get the remaining stages to complete *) 328let remaining_stages uiaa flow = 329 List.filter (fun stage -> not (List.mem stage uiaa.completed)) flow.stages 330 331(** UIAA-protected request wrapper. 332 333 This function handles the UIAA flow automatically: 334 1. Makes the initial request 335 2. If 401 with UIAA, calls the auth_callback to get auth data 336 3. Retries the request with auth data 337 4. Repeats until success or failure *) 338let with_uiaa ~make_request ~auth_callback = 339 match make_request None with 340 | Ok result -> Uiaa_success result 341 | Error e -> 342 (* Check if this is a UIAA challenge *) 343 match e with 344 | Error.Http_error { status = 401; body; _ } -> 345 (match parse_uiaa_response body with 346 | Some uiaa -> 347 (* Get auth data from callback *) 348 (match auth_callback uiaa with 349 | Some auth_data -> 350 (* Retry with auth *) 351 (match make_request (Some (auth_data_to_json auth_data)) with 352 | Ok result -> Uiaa_success result 353 | Error e2 -> 354 (* Check for another UIAA challenge (multi-stage) *) 355 (match e2 with 356 | Error.Http_error { status = 401; body = body2; _ } -> 357 (match parse_uiaa_response body2 with 358 | Some uiaa2 -> Uiaa_auth_required uiaa2 359 | None -> Uiaa_error e2) 360 | _ -> Uiaa_error e2)) 361 | None -> 362 Uiaa_auth_required uiaa) 363 | None -> Uiaa_error e) 364 | _ -> Uiaa_error e 365 366(** Helper to add auth field to a request body *) 367let add_auth_to_body body auth_json = 368 if String.length body < 2 then 369 Printf.sprintf {|{"auth":%s}|} auth_json 370 else 371 (* Insert auth field into existing JSON object *) 372 let trimmed = String.trim body in 373 if String.get trimmed 0 = '{' then 374 let content = String.sub trimmed 1 (String.length trimmed - 2) in 375 if String.length (String.trim content) = 0 then 376 Printf.sprintf {|{"auth":%s}|} auth_json 377 else 378 Printf.sprintf {|{"auth":%s,%s}|} auth_json (String.trim content) 379 else 380 body 381 382(** Verify email for 3PID binding. 383 First step: request token to be sent to email *) 384type request_token_response = { 385 sid : string; 386 submit_url : string option; 387} 388 389let request_token_response_jsont = 390 Jsont.Object.( 391 map (fun sid submit_url -> { sid; submit_url }) 392 |> mem "sid" Jsont.string ~enc:(fun t -> t.sid) 393 |> opt_mem "submit_url" Jsont.string ~enc:(fun t -> t.submit_url) 394 |> finish) 395 396(** Request a token for email validation *) 397let request_email_token client ~email ~client_secret ~send_attempt ?next_link () = 398 let path = "/account/3pid/email/requestToken" in 399 let next_link_part = match next_link with 400 | Some nl -> Printf.sprintf {|,"next_link":"%s"|} nl 401 | None -> "" 402 in 403 let body = Printf.sprintf 404 {|{"client_secret":"%s","email":"%s","send_attempt":%d%s}|} 405 client_secret email send_attempt next_link_part 406 in 407 match Client.post client ~path ~body () with 408 | Error e -> Error e 409 | Ok resp_body -> Client.decode_response request_token_response_jsont resp_body 410 411(** Request a token for phone number validation *) 412let request_msisdn_token client ~country ~phone_number ~client_secret ~send_attempt ?next_link () = 413 let path = "/account/3pid/msisdn/requestToken" in 414 let next_link_part = match next_link with 415 | Some nl -> Printf.sprintf {|,"next_link":"%s"|} nl 416 | None -> "" 417 in 418 let body = Printf.sprintf 419 {|{"client_secret":"%s","country":"%s","phone_number":"%s","send_attempt":%d%s}|} 420 client_secret country phone_number send_attempt next_link_part 421 in 422 match Client.post client ~path ~body () with 423 | Error e -> Error e 424 | Ok resp_body -> Client.decode_response request_token_response_jsont resp_body 425 426(** Validate a token (submit to identity server or homeserver) *) 427let validate_email_token client ~sid ~client_secret ~token = 428 let path = "/account/3pid/email/validate" in 429 let body = Printf.sprintf 430 {|{"sid":"%s","client_secret":"%s","token":"%s"}|} 431 sid client_secret token 432 in 433 match Client.post client ~path ~body () with 434 | Error e -> Error e 435 | Ok _ -> Ok ()