Gleam Lustre Fullstack Atproto Demo App w/Slices.Network GraphQL API
at main 965 lines 28 kB view raw
1import api/graphql as gql 2import api/profile_init 3import dotenv_gleam 4import envoy 5import gleam/bit_array 6import gleam/dynamic/decode 7import gleam/erlang/process 8import gleam/http.{Get, Post} 9import gleam/http/request 10import gleam/httpc 11import gleam/int 12import gleam/io 13import gleam/json 14import gleam/list 15import gleam/option.{type Option, None, Some} 16import gleam/result 17import gleam/string 18import gleam/string_tree 19import gleam/uri 20import lustre/attribute 21import lustre/element 22import lustre/element/html 23import mist 24import oauth/pkce 25import oauth/session 26import shared/api/graphql/get_profile 27import shared/api/graphql/update_profile 28import shared/api/types 29import sqlight 30import wisp.{type Request, type Response} 31import wisp/wisp_mist 32 33// OAUTH CONFIG ---------------------------------------------------------------- 34 35pub type OAuthConfig { 36 OAuthConfig( 37 client_id: String, 38 client_secret: String, 39 redirect_uri: String, 40 auth_url: String, 41 ) 42} 43 44fn load_oauth_config() -> OAuthConfig { 45 OAuthConfig( 46 client_id: envoy.get("OAUTH_CLIENT_ID") 47 |> result.unwrap(""), 48 client_secret: envoy.get("OAUTH_CLIENT_SECRET") 49 |> result.unwrap(""), 50 redirect_uri: envoy.get("OAUTH_REDIRECT_URI") 51 |> result.unwrap("http://localhost:3000/oauth/callback"), 52 auth_url: envoy.get("OAUTH_AUTH_URL") 53 |> result.unwrap("http://localhost:3001"), 54 ) 55} 56 57pub fn main() { 58 // Load environment variables from .env file 59 let _ = dotenv_gleam.config() 60 61 wisp.configure_logger() 62 63 // Load secret key from environment or generate one (for development) 64 let secret_key_base = case envoy.get("SECRET_KEY_BASE") { 65 Ok(key) -> key 66 Error(_) -> { 67 // In development, generate and warn 68 let generated = wisp.random_string(64) 69 io.println( 70 "WARNING: No SECRET_KEY_BASE found in environment. Using generated key: " 71 <> generated, 72 ) 73 io.println("Add this to your .env file: SECRET_KEY_BASE=" <> generated) 74 generated 75 } 76 } 77 78 let oauth_config = load_oauth_config() 79 80 let assert Ok(priv_directory) = wisp.priv_directory("server") 81 let static_directory = priv_directory <> "/static" 82 83 // Get database URL from environment or default to ./sessions.db 84 let database_url = case envoy.get("DATABASE_URL") { 85 Ok(url) -> url 86 Error(_) -> "./sessions.db" 87 } 88 89 // Initialize database 90 use db <- sqlight.with_connection(database_url) 91 let assert Ok(_) = session.init_db(db) 92 93 // Get host from environment or default to 127.0.0.1 94 let host = case envoy.get("HOST") { 95 Ok(h) -> h 96 Error(_) -> "127.0.0.1" 97 } 98 99 // Get port from environment or default to 3000 100 let port = case envoy.get("PORT") { 101 Ok(port_str) -> { 102 case int.parse(port_str) { 103 Ok(p) -> p 104 Error(_) -> 3000 105 } 106 } 107 Error(_) -> 3000 108 } 109 110 io.println("Listening on http://" <> host <> ":" <> int.to_string(port)) 111 112 let assert Ok(_) = 113 handle_request(static_directory, db, oauth_config, _) 114 |> wisp_mist.handler(secret_key_base) 115 |> mist.new 116 |> mist.bind(host) 117 |> mist.port(port) 118 |> mist.start 119 120 process.sleep_forever() 121} 122 123// REQUEST HANDLERS ------------------------------------------------------------ 124 125fn app_middleware( 126 req: Request, 127 static_directory: String, 128 next: fn(Request) -> Response, 129) -> Response { 130 let req = wisp.method_override(req) 131 use <- wisp.log_request(req) 132 use <- wisp.rescue_crashes 133 use req <- wisp.handle_head(req) 134 use <- wisp.serve_static(req, under: "/static", from: static_directory) 135 136 next(req) 137} 138 139fn require_profile_owner( 140 req: Request, 141 db: sqlight.Connection, 142 handle: String, 143 next: fn(String) -> Response, 144) -> Response { 145 case session.get_current_user(req, db) { 146 Error(_) -> { 147 wisp.log_warning("Unauthorized attempt to access profile: " <> handle) 148 wisp.json_response( 149 json.to_string( 150 json.object([#("error", json.string("Not authenticated"))]), 151 ), 152 401, 153 ) 154 } 155 Ok(#(_did, current_handle, access_token)) -> { 156 case current_handle == handle { 157 False -> { 158 wisp.log_warning( 159 "User " 160 <> current_handle 161 <> " attempted to access profile of " 162 <> handle, 163 ) 164 wisp.json_response( 165 json.to_string( 166 json.object([ 167 #("error", json.string("You can only edit your own profile")), 168 ]), 169 ), 170 403, 171 ) 172 } 173 True -> next(access_token) 174 } 175 } 176 } 177} 178 179fn handle_request( 180 static_directory: String, 181 db: sqlight.Connection, 182 oauth_config: OAuthConfig, 183 req: Request, 184) -> Response { 185 use req <- app_middleware(req, static_directory) 186 187 case req.method, wisp.path_segments(req) { 188 // OAuth routes 189 Get, ["login"] -> serve_index(option.None, req, db) 190 Post, ["oauth", "authorize"] -> 191 handle_oauth_authorize(req, db, oauth_config) 192 Get, ["oauth", "callback"] -> handle_oauth_callback(req, db, oauth_config) 193 Post, ["logout"] -> handle_logout(req, db) 194 195 // API endpoint to get current user 196 Get, ["api", "user", "current"] -> get_current_user_json(req, db) 197 198 // API endpoint to list all attendees 199 Get, ["api", "attendees"] -> fetch_attendees_json(req, db) 200 201 // API endpoint to fetch profile data as JSON 202 Get, ["api", "profile", handle] -> fetch_profile_json(handle, req, db) 203 204 // API endpoint to update profile 205 Post, ["api", "profile", handle, "update"] -> 206 update_profile_json(req, handle, db) 207 208 // Profile routes - prerender with data 209 Get, ["profile", handle] -> serve_profile(handle, req, db) 210 Get, ["profile", handle, "edit"] -> serve_profile(handle, req, db) 211 212 // Attendees page - prerender with data 213 Get, ["attendees"] -> serve_attendees(req, db) 214 215 // Everything else gets our base HTML 216 Get, _ -> serve_index(option.None, req, db) 217 218 // Fallback for other methods/paths 219 _, _ -> wisp.not_found() 220 } 221} 222 223pub type SSRData { 224 ProfileData(types.Profile) 225 AttendeesData(List(types.Profile)) 226} 227 228fn serve_index( 229 ssr_data: Option(SSRData), 230 req: Request, 231 db: sqlight.Connection, 232) -> Response { 233 // Get current user if authenticated 234 let user_json = case session.get_current_user(req, db) { 235 Ok(#(_did, handle, _access_token)) -> 236 Some(json.object([#("handle", json.string(handle))])) 237 Error(_) -> None 238 } 239 240 // Build model script with SSR data 241 let model_fields = case ssr_data { 242 Some(ProfileData(profile_val)) -> [ 243 #("profile", get_profile.org_atmosphereconf_profile_to_json(profile_val)), 244 ] 245 Some(AttendeesData(profiles)) -> [ 246 #( 247 "attendees", 248 json.array(profiles, get_profile.org_atmosphereconf_profile_to_json), 249 ), 250 ] 251 None -> [] 252 } 253 254 // Add user if authenticated 255 let model_fields = case user_json { 256 Some(user) -> [#("user", user), ..model_fields] 257 None -> model_fields 258 } 259 260 let model_script = case model_fields { 261 [] -> element.none() 262 _ -> 263 html.script( 264 [attribute.type_("application/json"), attribute.id("model")], 265 json.to_string(json.object(model_fields)), 266 ) 267 } 268 269 let html = 270 html.html([], [ 271 html.head([], [ 272 html.meta([attribute.attribute("charset", "utf-8")]), 273 html.meta([ 274 attribute.name("viewport"), 275 attribute.attribute("content", "width=device-width, initial-scale=1"), 276 ]), 277 html.title([], "Atmosphere Conf"), 278 html.script([attribute.src("https://cdn.tailwindcss.com")], ""), 279 html.script( 280 [attribute.type_("module"), attribute.src("/static/client.js")], 281 "", 282 ), 283 model_script, 284 ]), 285 html.body([attribute.class("bg-zinc-950 text-zinc-300 font-mono")], [ 286 html.div([attribute.id("app")], []), 287 ]), 288 ]) 289 290 html 291 |> element.to_document_string_tree 292 |> string_tree.to_string 293 |> wisp.html_response(200) 294} 295 296fn get_graphql_config(access_token: String) -> gql.Config { 297 gql.Config( 298 api_url: "https://api.slices.network/graphql", 299 slice_uri: "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3m3gc7lhwzx2z", 300 access_token: access_token, 301 ) 302} 303 304fn get_current_user_json(req: Request, db: sqlight.Connection) -> Response { 305 case session.get_current_user(req, db) { 306 Ok(#(did, handle, _access_token)) -> { 307 wisp.json_response( 308 json.to_string( 309 json.object([ 310 #("did", json.string(did)), 311 #("handle", json.string(handle)), 312 ]), 313 ), 314 200, 315 ) 316 } 317 Error(_) -> { 318 wisp.json_response( 319 json.to_string( 320 json.object([#("error", json.string("Not authenticated"))]), 321 ), 322 401, 323 ) 324 } 325 } 326} 327 328fn fetch_profile_json( 329 handle: String, 330 req: Request, 331 db: sqlight.Connection, 332) -> Response { 333 // Get access token from session if available 334 let access_token = case session.get_current_user(req, db) { 335 Ok(#(_, _, token)) -> token 336 Error(_) -> "" 337 } 338 339 let config = get_graphql_config(access_token) 340 341 wisp.log_info("API: Fetching profile for handle: " <> handle) 342 343 case gql.get_profile_by_handle(config, handle) { 344 Ok(option.Some(profile_val)) -> { 345 wisp.log_info("API: Profile found for handle: " <> handle) 346 json.to_string(get_profile.org_atmosphereconf_profile_to_json(profile_val)) 347 |> wisp.json_response(200) 348 } 349 Ok(option.None) -> { 350 wisp.log_warning("API: No profile found for handle: " <> handle) 351 wisp.json_response( 352 json.to_string( 353 json.object([#("error", json.string("Profile not found"))]), 354 ), 355 404, 356 ) 357 } 358 Error(err) -> { 359 wisp.log_error("API: Error fetching profile: " <> err) 360 wisp.json_response( 361 json.to_string(json.object([#("error", json.string(err))])), 362 500, 363 ) 364 } 365 } 366} 367 368fn fetch_attendees_json(req: Request, db: sqlight.Connection) -> Response { 369 // Get access token from session if available 370 let access_token = case session.get_current_user(req, db) { 371 Ok(#(_, _, token)) -> token 372 Error(_) -> "" 373 } 374 375 let config = get_graphql_config(access_token) 376 377 wisp.log_info("API: Fetching all attendees") 378 379 case gql.list_profiles(config) { 380 Ok(profiles) -> { 381 wisp.log_info( 382 "API: Found " <> int.to_string(list.length(profiles)) <> " profiles", 383 ) 384 let profiles_json = 385 json.array(profiles, get_profile.org_atmosphereconf_profile_to_json) 386 json.to_string(profiles_json) 387 |> wisp.json_response(200) 388 } 389 Error(err) -> { 390 wisp.log_error("API: Error fetching attendees: " <> err) 391 wisp.json_response( 392 json.to_string(json.object([#("error", json.string(err))])), 393 500, 394 ) 395 } 396 } 397} 398 399fn serve_profile( 400 handle: String, 401 req: Request, 402 db: sqlight.Connection, 403) -> Response { 404 // Get access token from session if available 405 let access_token = case session.get_current_user(req, db) { 406 Ok(#(_, _, token)) -> token 407 Error(_) -> "" 408 } 409 410 let config = get_graphql_config(access_token) 411 412 wisp.log_info("SSR: Fetching profile for handle: " <> handle) 413 414 let ssr_data = case gql.get_profile_by_handle(config, handle) { 415 Ok(option.Some(profile_val)) -> { 416 wisp.log_info("SSR: Profile found for handle: " <> handle) 417 option.Some(ProfileData(profile_val)) 418 } 419 Ok(option.None) -> { 420 wisp.log_warning("SSR: No profile found for handle: " <> handle) 421 option.None 422 } 423 Error(err) -> { 424 wisp.log_error("SSR: Error fetching profile: " <> err) 425 option.None 426 } 427 } 428 429 serve_index(ssr_data, req, db) 430} 431 432fn serve_attendees(req: Request, db: sqlight.Connection) -> Response { 433 // Get access token from session if available 434 let access_token = case session.get_current_user(req, db) { 435 Ok(#(_, _, token)) -> token 436 Error(_) -> "" 437 } 438 439 let config = get_graphql_config(access_token) 440 441 wisp.log_info("SSR: Fetching attendees list") 442 443 let ssr_data = case gql.list_profiles(config) { 444 Ok(profiles) -> { 445 wisp.log_info( 446 "SSR: Found " <> int.to_string(list.length(profiles)) <> " profiles", 447 ) 448 option.Some(AttendeesData(profiles)) 449 } 450 Error(err) -> { 451 wisp.log_error("SSR: Error fetching attendees: " <> err) 452 option.None 453 } 454 } 455 456 serve_index(ssr_data, req, db) 457} 458 459fn update_profile_json( 460 req: Request, 461 handle: String, 462 db: sqlight.Connection, 463) -> Response { 464 use access_token <- require_profile_owner(req, db, handle) 465 466 let config = get_graphql_config(access_token) 467 468 wisp.log_info("API: Updating profile for handle: " <> handle) 469 470 // Parse request body 471 use body <- wisp.require_string_body(req) 472 473 // Helper to decode optional fields that may be missing 474 let optional_field = fn(parsed: decode.Dynamic, path: String, decoder: decode.Decoder(a)) -> Option(a) { 475 decode.run(parsed, decode.at([path], decode.optional(decoder))) 476 |> result.unwrap(None) 477 } 478 479 // Decode JSON using Squall-generated decoders 480 let update_result = { 481 use parsed <- result.try( 482 json.parse(body, decode.dynamic) 483 |> result.map_error(fn(_) { "Invalid JSON" }), 484 ) 485 486 // Decode profile fields 487 let display_name = optional_field(parsed, "displayName", decode.string) 488 let description = optional_field(parsed, "description", decode.string) 489 let home_town = 490 optional_field( 491 parsed, 492 "homeTown", 493 update_profile.community_lexicon_location_hthree_decoder(), 494 ) 495 |> option.map(update_profile.community_lexicon_location_hthree_to_json) 496 let interests = optional_field(parsed, "interests", decode.list(decode.string)) 497 let created_at = optional_field(parsed, "createdAt", decode.string) 498 499 let profile_input = 500 update_profile.OrgAtmosphereconfProfileInput( 501 display_name: display_name, 502 description: description, 503 home_town: home_town, 504 interests: interests, 505 avatar: None, 506 created_at: created_at, 507 ) 508 509 // Extract avatar upload fields separately (not part of GraphQL input) 510 let avatar_base64 = optional_field(parsed, "avatarBase64", decode.string) 511 let avatar_mime_type = optional_field(parsed, "avatarMimeType", decode.string) 512 513 Ok(#(profile_input, avatar_base64, avatar_mime_type)) 514 } 515 516 case update_result { 517 Ok(#(update, avatar_base64, avatar_mime_type)) -> { 518 // Determine which avatar to use 519 let avatar_blob = case avatar_base64, avatar_mime_type { 520 Some(base64), Some(mime) -> { 521 // New avatar uploaded - upload the blob 522 case gql.upload_blob(config, base64, mime) { 523 Ok(blob) -> Some(blob) 524 Error(err) -> { 525 wisp.log_error("API: Failed to upload avatar blob: " <> err) 526 None 527 } 528 } 529 } 530 _, _ -> { 531 // No new avatar - fetch current profile and use existing avatar blob 532 case gql.get_profile_by_handle(config, handle) { 533 Ok(Some(current_profile)) -> { 534 // Use existing avatar blob if present 535 case current_profile.avatar { 536 Some(blob) -> { 537 // Convert Blob to JSON for the mutation 538 Some( 539 json.object([ 540 #("ref", json.string(blob.ref)), 541 #("mimeType", json.string(blob.mime_type)), 542 #("size", json.int(blob.size)), 543 ]), 544 ) 545 } 546 None -> None 547 } 548 } 549 _ -> None 550 } 551 } 552 } 553 554 // Create final update with avatar blob if available 555 let final_update = 556 update_profile.OrgAtmosphereconfProfileInput( 557 ..update, 558 avatar: avatar_blob, 559 ) 560 561 case gql.update_profile(config, handle, final_update) { 562 Ok(updated_profile) -> { 563 wisp.log_info("API: Profile updated successfully for: " <> handle) 564 wisp.json_response( 565 json.to_string(get_profile.org_atmosphereconf_profile_to_json( 566 updated_profile, 567 )), 568 200, 569 ) 570 } 571 Error(err) -> { 572 wisp.log_error("API: Failed to update profile: " <> err) 573 wisp.json_response( 574 json.to_string(json.object([#("error", json.string(err))])), 575 500, 576 ) 577 } 578 } 579 } 580 Error(err) -> { 581 wisp.log_error("API: Failed to parse update request: " <> err) 582 wisp.json_response( 583 json.to_string(json.object([#("error", json.string(err))])), 584 400, 585 ) 586 } 587 } 588} 589 590// OAUTH HANDLERS -------------------------------------------------------------- 591 592fn handle_oauth_authorize( 593 req: Request, 594 db: sqlight.Connection, 595 config: OAuthConfig, 596) -> Response { 597 use formdata <- wisp.require_form(req) 598 599 // Get login hint from form 600 let login_hint = case formdata.values { 601 [#("loginHint", hint), ..] -> hint 602 _ -> "" 603 } 604 605 wisp.log_info("OAuth: Authorization requested for: " <> login_hint) 606 607 // Generate PKCE parameters 608 let code_verifier = pkce.generate_code_verifier() 609 let code_challenge = pkce.generate_code_challenge(code_verifier) 610 let state = session.generate_session_id() 611 612 // Store PKCE state 613 let oauth_state = 614 session.OAuthState( 615 code_verifier: code_verifier, 616 code_challenge: code_challenge, 617 login_hint: login_hint, 618 ) 619 let _ = session.save_oauth_state(db, state, oauth_state) 620 621 // Build authorization URL 622 let query_params = [ 623 #("response_type", "code"), 624 #("client_id", config.client_id), 625 #("redirect_uri", config.redirect_uri), 626 #("state", state), 627 #("code_challenge", code_challenge), 628 #("code_challenge_method", "S256"), 629 #("scope", "profile openid atproto transition:generic"), 630 #("login_hint", login_hint), 631 ] 632 633 let full_auth_url = config.auth_url <> "/oauth/authorize" 634 635 let auth_uri = case uri.parse(full_auth_url) { 636 Ok(base_uri) -> { 637 let query_string = 638 query_params 639 |> list_to_query_string 640 641 uri.Uri(..base_uri, query: Some(query_string)) 642 |> uri.to_string 643 } 644 Error(_) -> full_auth_url 645 } 646 647 wisp.log_info("OAuth: Redirecting to: " <> auth_uri) 648 wisp.redirect(auth_uri) 649} 650 651fn list_to_query_string(params: List(#(String, String))) -> String { 652 params 653 |> list.map(fn(pair) { 654 let #(key, value) = pair 655 uri.percent_encode(key) <> "=" <> uri.percent_encode(value) 656 }) 657 |> string.join("&") 658} 659 660fn handle_oauth_callback( 661 req: Request, 662 db: sqlight.Connection, 663 config: OAuthConfig, 664) -> Response { 665 // Get code from query params 666 let code = case req.query { 667 Some(query_string) -> { 668 case uri.parse_query(query_string) { 669 Ok(params) -> list.key_find(params, "code") |> result.unwrap("missing") 670 Error(_) -> "missing" 671 } 672 } 673 None -> "missing" 674 } 675 676 // Get state from query params 677 let state = case req.query { 678 Some(query_string) -> { 679 case uri.parse_query(query_string) { 680 Ok(params) -> list.key_find(params, "state") |> result.unwrap("missing") 681 Error(_) -> "missing" 682 } 683 } 684 None -> "missing" 685 } 686 687 // Validate we have both 688 case code == "missing" || state == "missing" { 689 True -> { 690 wisp.log_error("OAuth: Missing code or state in callback") 691 wisp.redirect("/login?error=Missing+parameters") 692 } 693 False -> { 694 wisp.log_info("OAuth: Callback received with code and state") 695 696 // Retrieve PKCE code_verifier from state 697 case session.get_oauth_state(db, state) { 698 Ok(oauth_state) -> { 699 // Clean up the OAuth state 700 let _ = session.delete_oauth_state(db, state) 701 702 wisp.log_info("OAuth: Exchanging code for tokens") 703 704 let token_url = config.auth_url <> "/oauth/token" 705 706 case 707 exchange_code_for_tokens( 708 token_url, 709 code, 710 oauth_state.code_verifier, 711 config.client_id, 712 config.client_secret, 713 config.redirect_uri, 714 ) 715 { 716 Ok(token_response) -> { 717 wisp.log_info("OAuth: Successfully exchanged code for tokens") 718 719 // Fetch user info 720 let userinfo_url = config.auth_url <> "/oauth/userinfo" 721 case get_user_info(userinfo_url, token_response.access_token) { 722 Ok(user_info) -> { 723 wisp.log_info("OAuth: Got user info") 724 wisp.log_info(" DID: " <> user_info.did) 725 wisp.log_info( 726 " Handle: " <> option.unwrap(user_info.handle, "(none)"), 727 ) 728 729 // Initialize user profile (silent failure) 730 let graphql_config = 731 gql.Config( 732 api_url: "https://api.slices.network/graphql", 733 slice_uri: "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3m3gc7lhwzx2z", 734 access_token: token_response.access_token, 735 ) 736 737 let _ = 738 profile_init.initialize_user_profile( 739 graphql_config, 740 user_info.did, 741 option.unwrap(user_info.handle, ""), 742 ) 743 744 case 745 session.create_session( 746 db, 747 token_response.access_token, 748 token_response.refresh_token, 749 user_info.did, 750 option.unwrap(user_info.handle, ""), 751 ) 752 { 753 Ok(session_id) -> { 754 wisp.redirect("/") 755 |> session.set_session_cookie(req, session_id) 756 } 757 Error(_err) -> { 758 wisp.log_error("OAuth: Failed to create session") 759 wisp.redirect("/login?error=Session+creation+failed") 760 } 761 } 762 } 763 Error(err) -> { 764 wisp.log_error("OAuth: Failed to get user info: " <> err) 765 wisp.redirect("/login?error=Failed+to+get+user+info") 766 } 767 } 768 } 769 Error(err) -> { 770 wisp.log_error("OAuth: Token exchange failed: " <> err) 771 wisp.redirect("/login?error=Token+exchange+failed") 772 } 773 } 774 } 775 Error(_) -> { 776 wisp.log_error("OAuth: Invalid or expired state") 777 wisp.redirect("/login?error=Invalid+state") 778 } 779 } 780 } 781 } 782} 783 784fn handle_logout(req: Request, db: sqlight.Connection) -> Response { 785 // Get session ID and delete session 786 case session.get_session_id(req) { 787 Ok(session_id) -> { 788 let _ = session.delete_session(db, session_id) 789 wisp.log_info("User logged out") 790 } 791 Error(_) -> Nil 792 } 793 794 // Clear cookie and redirect 795 wisp.redirect("/") 796 |> session.clear_session_cookie(req) 797} 798 799// TOKEN EXCHANGE -------------------------------------------------------------- 800 801type TokenResponse { 802 TokenResponse(access_token: String, refresh_token: Option(String)) 803} 804 805type UserInfo { 806 UserInfo(sub: String, did: String, handle: Option(String)) 807} 808 809fn exchange_code_for_tokens( 810 token_url: String, 811 code: String, 812 code_verifier: String, 813 client_id: String, 814 client_secret: String, 815 redirect_uri: String, 816) -> Result(TokenResponse, String) { 817 // Build form-encoded body (without client credentials) 818 let body_params = [ 819 #("grant_type", "authorization_code"), 820 #("code", code), 821 #("redirect_uri", redirect_uri), 822 #("client_id", client_id), 823 #("code_verifier", code_verifier), 824 ] 825 826 let body = list_to_query_string(body_params) 827 828 // Create Basic Auth header 829 let credentials = client_id <> ":" <> client_secret 830 let credentials_bytes = bit_array.from_string(credentials) 831 let basic_auth = "Basic " <> bit_array.base64_encode(credentials_bytes, True) 832 833 // Create HTTP request 834 case request.to(token_url) { 835 Ok(req) -> { 836 let req = 837 req 838 |> request.set_method(Post) 839 |> request.set_header("authorization", basic_auth) 840 |> request.set_header( 841 "content-type", 842 "application/x-www-form-urlencoded", 843 ) 844 |> request.set_body(body) 845 846 // Send request 847 case httpc.send(req) { 848 Ok(resp) -> { 849 case resp.status { 850 200 -> { 851 // Parse JSON response 852 case json.parse(resp.body, decode.dynamic) { 853 Ok(parsed) -> { 854 // Extract fields from token response 855 let access_token = case 856 decode.run( 857 parsed, 858 decode.at(["access_token"], decode.string), 859 ) 860 { 861 Ok(token) -> token 862 Error(_) -> "" 863 } 864 865 let refresh_token = case 866 decode.run( 867 parsed, 868 decode.at( 869 ["refresh_token"], 870 decode.optional(decode.string), 871 ), 872 ) 873 { 874 Ok(token) -> token 875 Error(_) -> None 876 } 877 878 case access_token == "" { 879 True -> Error("Missing access_token in token response") 880 False -> 881 Ok(TokenResponse( 882 access_token: access_token, 883 refresh_token: refresh_token, 884 )) 885 } 886 } 887 Error(_) -> Error("Failed to parse token response JSON") 888 } 889 } 890 _ -> 891 Error( 892 "Token request failed with status: " 893 <> string.inspect(resp.status), 894 ) 895 } 896 } 897 Error(_) -> Error("Failed to send token request") 898 } 899 } 900 Error(_) -> Error("Invalid token URL") 901 } 902} 903 904fn get_user_info( 905 userinfo_url: String, 906 access_token: String, 907) -> Result(UserInfo, String) { 908 case request.to(userinfo_url) { 909 Ok(req) -> { 910 let req = 911 req 912 |> request.set_method(Get) 913 |> request.set_header("authorization", "Bearer " <> access_token) 914 915 case httpc.send(req) { 916 Ok(resp) -> { 917 case resp.status { 918 200 -> { 919 case json.parse(resp.body, decode.dynamic) { 920 Ok(parsed) -> { 921 let sub = case 922 decode.run(parsed, decode.at(["sub"], decode.string)) 923 { 924 Ok(s) -> s 925 Error(_) -> "" 926 } 927 928 let did = case 929 decode.run(parsed, decode.at(["did"], decode.string)) 930 { 931 Ok(d) -> d 932 Error(_) -> sub 933 } 934 935 let handle = case 936 decode.run( 937 parsed, 938 decode.at(["name"], decode.optional(decode.string)), 939 ) 940 { 941 Ok(h) -> h 942 Error(_) -> None 943 } 944 945 case sub == "" { 946 True -> Error("Missing sub in userinfo response") 947 False -> Ok(UserInfo(sub: sub, did: did, handle: handle)) 948 } 949 } 950 Error(_) -> Error("Failed to parse userinfo response JSON") 951 } 952 } 953 _ -> 954 Error( 955 "Userinfo request failed with status: " 956 <> string.inspect(resp.status), 957 ) 958 } 959 } 960 Error(_) -> Error("Failed to send userinfo request") 961 } 962 } 963 Error(_) -> Error("Invalid userinfo URL") 964 } 965}