···11+use crate::{
22+ AppState,
33+ error_response,
44+ session::AxumSessionStore,
55+};
66+use axum::{
77+ extract::{Path, State},
88+ http::StatusCode,
99+ response::{IntoResponse, Redirect, Response},
1010+};
1111+use shared::advent::challenges::day_five::DayFive;
1212+use super::log_and_respond;
1313+1414+/// Endpoint for day 5: creates a record on the secret agent's repo with the user's verification code,
1515+/// then redirects back to /day/5 so the user can find the code via firehose/jetstream.
1616+pub async fn create_record_handler(
1717+ Path(user_did): Path<String>,
1818+ state: State<AppState>,
1919+ session: AxumSessionStore,
2020+) -> Result<impl IntoResponse, Response> {
2121+ // Verify the user is logged in and the DID matches
2222+ let session_did = session.get_did().ok_or_else(|| {
2323+ error_response(
2424+ StatusCode::FORBIDDEN,
2525+ "You need to be logged in to do this",
2626+ )
2727+ })?;
2828+2929+ if session_did != user_did {
3030+ return Err(error_response(
3131+ StatusCode::FORBIDDEN,
3232+ "You can only trigger this for your own account",
3333+ ));
3434+ }
3535+3636+ let day_five = DayFive {
3737+ pool: state.postgres_pool.clone(),
3838+ oauth_client: None,
3939+ secret_agent: state.secret_agent.clone(),
4040+ };
4141+4242+ day_five
4343+ .create_secret_record(&user_did)
4444+ .await
4545+ .map_err(log_and_respond(
4646+ StatusCode::INTERNAL_SERVER_ERROR,
4747+ "Error creating the secret record",
4848+ ))?;
4949+5050+ Ok(Redirect::to("/day/5"))
5151+}
+164
web/src/handlers/custom/day_six.rs
···11+use crate::AppState;
22+use atrium_api::types::string::Did;
33+use atrium_common::resolver::Resolver;
44+use axum::{
55+ Json,
66+ extract::State,
77+ http::{HeaderMap, StatusCode},
88+ response::{IntoResponse, Response},
99+};
1010+use serde_json::json;
1111+use shared::advent::challenges::day_six::{self};
1212+use shared::atrium::service_auth::{
1313+ decode_and_verify_service_auth, decode_jwt_claims, extract_signing_key_bytes,
1414+};
1515+1616+/// XRPC endpoint for Day 6 Part 2: verifies a serviceAuth JWT from the Authorization header
1717+/// and returns the user's verification code.
1818+pub async fn xrpc_handler(
1919+ State(state): State<AppState>,
2020+ headers: HeaderMap,
2121+) -> Result<Json<serde_json::Value>, Response> {
2222+ let xrpc_error = |status: StatusCode, error: &str, message: &str| -> Response {
2323+ (status, Json(json!({"error": error, "message": message}))).into_response()
2424+ };
2525+2626+ // Extract Bearer token from Authorization header
2727+ let auth_header = headers
2828+ .get("authorization")
2929+ .and_then(|v| v.to_str().ok())
3030+ .ok_or_else(|| {
3131+ xrpc_error(
3232+ StatusCode::UNAUTHORIZED,
3333+ "AuthMissing",
3434+ "Missing Authorization header",
3535+ )
3636+ })?;
3737+3838+ let jwt = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
3939+ xrpc_error(
4040+ StatusCode::UNAUTHORIZED,
4141+ "AuthMissing",
4242+ "Authorization header must use Bearer scheme",
4343+ )
4444+ })?;
4545+4646+ // Decode claims to get issuer
4747+ let claims = decode_jwt_claims(jwt).map_err(|e| {
4848+ xrpc_error(
4949+ StatusCode::UNAUTHORIZED,
5050+ "InvalidToken",
5151+ &format!("Could not decode JWT: {e}"),
5252+ )
5353+ })?;
5454+5555+ let expected_service_did = format!(
5656+ "did:web:{}",
5757+ std::env::var("OAUTH_HOST").unwrap_or_default()
5858+ );
5959+6060+ // Check aud
6161+ if claims.aud != expected_service_did {
6262+ return Err(xrpc_error(
6363+ StatusCode::UNAUTHORIZED,
6464+ "InvalidToken",
6565+ &format!(
6666+ "JWT audience '{}' does not match expected '{}'",
6767+ claims.aud, expected_service_did
6868+ ),
6969+ ));
7070+ }
7171+7272+ // Check lxm
7373+ match &claims.lxm {
7474+ Some(lxm) if lxm == day_six::LXM_PART_TWO => {}
7575+ Some(lxm) => {
7676+ return Err(xrpc_error(
7777+ StatusCode::UNAUTHORIZED,
7878+ "InvalidToken",
7979+ &format!(
8080+ "JWT lxm '{}' does not match expected '{}'",
8181+ lxm,
8282+ day_six::LXM_PART_TWO
8383+ ),
8484+ ));
8585+ }
8686+ None => {
8787+ return Err(xrpc_error(
8888+ StatusCode::UNAUTHORIZED,
8989+ "InvalidToken",
9090+ "JWT is missing the lxm claim",
9191+ ));
9292+ }
9393+ }
9494+9595+ // Resolve DID document and verify signature
9696+ let iss_did: Did = claims.iss.parse().map_err(|_| {
9797+ xrpc_error(
9898+ StatusCode::BAD_REQUEST,
9999+ "InvalidToken",
100100+ "Invalid DID in JWT iss claim",
101101+ )
102102+ })?;
103103+ let did_doc = state
104104+ .handle_resolver
105105+ .resolve(&iss_did)
106106+ .await
107107+ .map_err(|e| {
108108+ log::error!("Failed to resolve DID for {}: {}", claims.iss, e);
109109+ xrpc_error(
110110+ StatusCode::INTERNAL_SERVER_ERROR,
111111+ "InternalError",
112112+ "Failed to resolve DID document",
113113+ )
114114+ })?;
115115+116116+ let (key_alg, key_bytes) = extract_signing_key_bytes(&did_doc).map_err(|e| {
117117+ xrpc_error(
118118+ StatusCode::UNAUTHORIZED,
119119+ "InvalidToken",
120120+ &format!("Could not extract signing key: {e}"),
121121+ )
122122+ })?;
123123+124124+ decode_and_verify_service_auth(jwt, &key_bytes, key_alg).map_err(|e| {
125125+ xrpc_error(
126126+ StatusCode::UNAUTHORIZED,
127127+ "InvalidToken",
128128+ &format!("JWT verification failed: {e}"),
129129+ )
130130+ })?;
131131+132132+ // Look up the user's day 6 challenge record
133133+ let challenge = sqlx::query_as::<_, shared::models::db_models::ChallengeProgress>(
134134+ "SELECT * FROM challenges WHERE user_did = $1 AND day = $2",
135135+ )
136136+ .bind(&claims.iss)
137137+ .bind(6i16)
138138+ .fetch_optional(&state.postgres_pool)
139139+ .await
140140+ .map_err(|e| {
141141+ log::error!("DB error looking up day 6 challenge: {}", e);
142142+ xrpc_error(
143143+ StatusCode::INTERNAL_SERVER_ERROR,
144144+ "InternalError",
145145+ "Database error",
146146+ )
147147+ })?;
148148+149149+ match challenge {
150150+ Some(c) => match c.verification_code_two {
151151+ Some(code) => Ok(Json(json!({"code": code}))),
152152+ None => Err(xrpc_error(
153153+ StatusCode::BAD_REQUEST,
154154+ "NotReady",
155155+ "Part 2 has not been started yet. Complete Part 1 and visit the Day 6 page first.",
156156+ )),
157157+ },
158158+ None => Err(xrpc_error(
159159+ StatusCode::BAD_REQUEST,
160160+ "NotReady",
161161+ "You haven't started Day 6 yet. Visit the Day 6 page first.",
162162+ )),
163163+ }
164164+}
+4
web/src/handlers/custom/mod.rs
···11+pub mod day_five;
22+pub mod day_six;
33+44+use super::log_and_respond;
+10-203
web/src/handlers/day.rs
···33use crate::{AppState, error_response};
44use atrium_api::agent::Agent;
55use atrium_api::types::string::Did;
66-use atrium_common::resolver::Resolver;
76use axum::{
88- Json,
97 extract::{Form, Path, State},
1010- http::{HeaderMap, StatusCode},
88+ http::StatusCode,
119 response::{IntoResponse, Redirect, Response},
1212-};
1313-use serde_json::json;
1414-use shared::advent::challenges::day_five::DayFive;
1515-use shared::advent::challenges::day_four::DayFour;
1616-use shared::advent::challenges::day_six::{self, DaySix};
1717-use shared::atrium::service_auth::{
1818- decode_and_verify_service_auth, decode_jwt_claims, extract_signing_key_bytes,
1910};
2011use shared::{
2112 OAuthAgentType, OAuthClientType,
2213 advent::ChallengeCheckResponse,
2323- advent::challenges::day_one::DayOne,
2424- advent::challenges::day_three::DayThree,
2525- advent::challenges::day_two::DayTwo,
2614 advent::day::Day,
2715 advent::{AdventChallenge, AdventError},
2816 advent::{AdventPart, CompletionStatus},
1717+};
1818+use shared::advent::challenges::{
1919+ day_one::DayOne,
2020+ day_two::DayTwo,
2121+ day_three::DayThree,
2222+ day_four::DayFour,
2323+ day_five::DayFive,
2424+ day_six::DaySix,
2925};
30263127fn pick_day(
···9288 }
9389}
94909595-fn log_and_respond<E: std::fmt::Display>(
9191+pub(super) fn log_and_respond<E: std::fmt::Display>(
9692 status: StatusCode,
9793 context: &'static str,
9894) -> impl FnOnce(E) -> Response {
···483479 }
484480 }
485481}
486486-487487-/// Endpoint for day 5: creates a record on the secret agent's repo with the user's verification code,
488488-/// then redirects back to /day/5 so the user can find the code via firehose/jetstream.
489489-pub async fn day_five_create_record_handler(
490490- Path(user_did): Path<String>,
491491- state: State<AppState>,
492492- session: AxumSessionStore,
493493-) -> Result<impl IntoResponse, Response> {
494494- // Verify the user is logged in and the DID matches
495495- let session_did = session.get_did().ok_or_else(|| {
496496- error_response(
497497- StatusCode::FORBIDDEN,
498498- "You need to be logged in to do this",
499499- )
500500- })?;
501501-502502- if session_did != user_did {
503503- return Err(error_response(
504504- StatusCode::FORBIDDEN,
505505- "You can only trigger this for your own account",
506506- ));
507507- }
508508-509509- let day_five = DayFive {
510510- pool: state.postgres_pool.clone(),
511511- oauth_client: None,
512512- secret_agent: state.secret_agent.clone(),
513513- };
514514-515515- day_five
516516- .create_secret_record(&user_did)
517517- .await
518518- .map_err(log_and_respond(
519519- StatusCode::INTERNAL_SERVER_ERROR,
520520- "Error creating the secret record",
521521- ))?;
522522-523523- Ok(Redirect::to("/day/5"))
524524-}
525525-526526-/// XRPC endpoint for Day 6 Part 2: verifies a serviceAuth JWT from the Authorization header
527527-/// and returns the user's verification code.
528528-pub async fn day_six_xrpc_handler(
529529- State(state): State<AppState>,
530530- headers: HeaderMap,
531531-) -> Result<Json<serde_json::Value>, Response> {
532532- let xrpc_error = |status: StatusCode, error: &str, message: &str| -> Response {
533533- (status, Json(json!({"error": error, "message": message}))).into_response()
534534- };
535535-536536- // Extract Bearer token from Authorization header
537537- let auth_header = headers
538538- .get("authorization")
539539- .and_then(|v| v.to_str().ok())
540540- .ok_or_else(|| {
541541- xrpc_error(
542542- StatusCode::UNAUTHORIZED,
543543- "AuthMissing",
544544- "Missing Authorization header",
545545- )
546546- })?;
547547-548548- let jwt = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
549549- xrpc_error(
550550- StatusCode::UNAUTHORIZED,
551551- "AuthMissing",
552552- "Authorization header must use Bearer scheme",
553553- )
554554- })?;
555555-556556- // Decode claims to get issuer
557557- let claims = decode_jwt_claims(jwt).map_err(|e| {
558558- xrpc_error(
559559- StatusCode::UNAUTHORIZED,
560560- "InvalidToken",
561561- &format!("Could not decode JWT: {e}"),
562562- )
563563- })?;
564564-565565- let expected_service_did = format!(
566566- "did:web:{}",
567567- std::env::var("OAUTH_HOST").unwrap_or_default()
568568- );
569569-570570- // Check aud
571571- if claims.aud != expected_service_did {
572572- return Err(xrpc_error(
573573- StatusCode::UNAUTHORIZED,
574574- "InvalidToken",
575575- &format!(
576576- "JWT audience '{}' does not match expected '{}'",
577577- claims.aud, expected_service_did
578578- ),
579579- ));
580580- }
581581-582582- // Check lxm
583583- match &claims.lxm {
584584- Some(lxm) if lxm == day_six::LXM_PART_TWO => {}
585585- Some(lxm) => {
586586- return Err(xrpc_error(
587587- StatusCode::UNAUTHORIZED,
588588- "InvalidToken",
589589- &format!(
590590- "JWT lxm '{}' does not match expected '{}'",
591591- lxm,
592592- day_six::LXM_PART_TWO
593593- ),
594594- ));
595595- }
596596- None => {
597597- return Err(xrpc_error(
598598- StatusCode::UNAUTHORIZED,
599599- "InvalidToken",
600600- "JWT is missing the lxm claim",
601601- ));
602602- }
603603- }
604604-605605- // Resolve DID document and verify signature
606606- let iss_did: Did = claims.iss.parse().map_err(|_| {
607607- xrpc_error(
608608- StatusCode::BAD_REQUEST,
609609- "InvalidToken",
610610- "Invalid DID in JWT iss claim",
611611- )
612612- })?;
613613- let did_doc = state
614614- .handle_resolver
615615- .resolve(&iss_did)
616616- .await
617617- .map_err(|e| {
618618- log::error!("Failed to resolve DID for {}: {}", claims.iss, e);
619619- xrpc_error(
620620- StatusCode::INTERNAL_SERVER_ERROR,
621621- "InternalError",
622622- "Failed to resolve DID document",
623623- )
624624- })?;
625625-626626- let (key_alg, key_bytes) = extract_signing_key_bytes(&did_doc).map_err(|e| {
627627- xrpc_error(
628628- StatusCode::UNAUTHORIZED,
629629- "InvalidToken",
630630- &format!("Could not extract signing key: {e}"),
631631- )
632632- })?;
633633-634634- decode_and_verify_service_auth(jwt, &key_bytes, key_alg).map_err(|e| {
635635- xrpc_error(
636636- StatusCode::UNAUTHORIZED,
637637- "InvalidToken",
638638- &format!("JWT verification failed: {e}"),
639639- )
640640- })?;
641641-642642- // Look up the user's day 6 challenge record
643643- let challenge = sqlx::query_as::<_, shared::models::db_models::ChallengeProgress>(
644644- "SELECT * FROM challenges WHERE user_did = $1 AND day = $2",
645645- )
646646- .bind(&claims.iss)
647647- .bind(6i16)
648648- .fetch_optional(&state.postgres_pool)
649649- .await
650650- .map_err(|e| {
651651- log::error!("DB error looking up day 6 challenge: {}", e);
652652- xrpc_error(
653653- StatusCode::INTERNAL_SERVER_ERROR,
654654- "InternalError",
655655- "Database error",
656656- )
657657- })?;
658658-659659- match challenge {
660660- Some(c) => match c.verification_code_two {
661661- Some(code) => Ok(Json(json!({"code": code}))),
662662- None => Err(xrpc_error(
663663- StatusCode::BAD_REQUEST,
664664- "NotReady",
665665- "Part 2 has not been started yet. Complete Part 1 and visit the Day 6 page first.",
666666- )),
667667- },
668668- None => Err(xrpc_error(
669669- StatusCode::BAD_REQUEST,
670670- "NotReady",
671671- "You haven't started Day 6 yet. Visit the Day 6 page first.",
672672- )),
673673- }
674674-}
+3
web/src/handlers/mod.rs
···11pub mod auth;
22+pub mod custom;
23pub mod day;
34pub mod did;
45pub mod leaderboard;
56pub mod oauth_metadata;
77+88+use day::log_and_respond;