Gleam Lustre Fullstack Atproto Demo App w/Slices.Network GraphQL API
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}