···11-Day six is about XRPC. First up is we have them create an LXM jwt. We validate it's for the correct lxm and did.11+## Service Authentication (serviceAuth)
22+33+In the AT Protocol, services authenticate requests between each other using **serviceAuth** — a signed JWT token. When your PDS proxies a request to another service on your behalf, it creates a JWT signed with your account's signing key.
44+55+For this challenge, you'll create a serviceAuth JWT yourself. Here's what you need:
66+77+### JWT Structure
88+99+Your JWT must have these **header** fields:
1010+- `alg`: `ES256` (if your signing key is P-256) or `ES256K` (if secp256k1)
1111+- `typ`: `JWT`
1212+1313+And these **payload** claims:
1414+- `iss`: Your DID (e.g. `did:plc:abc123...`)
1515+- `aud`: `{{service_did}}`
1616+- `lxm`: `{{lxm_part_one}}`
1717+- `exp`: A UNIX timestamp in the future (current time + 60 seconds works)
1818+- `iat`: Current UNIX timestamp
1919+2020+Sign it with your account's **repo signing key** — the same key listed in your DID document's verification method.
2121+2222+The resulting JWT is three base64url-encoded segments joined by dots: `header.payload.signature`
2323+2424+### Submit Your JWT
2525+2626+Paste the raw JWT string below and we'll verify the signature against your DID document.
+18-1
shared/challenges_markdown/six/part_two.md
···11-Part 2 is them making an xrpc request and if set properly we give them the day.11+## XRPC Service Proxying
22+33+Now that you can create a serviceAuth JWT, let's use it in an actual XRPC request.
44+55+Make an HTTP GET request to our XRPC endpoint with your JWT in the `Authorization` header:
66+77+```
88+GET https://{{service_did_domain}}/xrpc/codes.advent.challenge.getDay6Code
99+Authorization: Bearer <your-jwt>
1010+```
1111+1212+Your JWT for this request must have:
1313+- `iss`: Your DID
1414+- `aud`: `{{service_did}}`
1515+- `lxm`: `{{lxm_part_two}}`
1616+- `exp`: A UNIX timestamp in the future
1717+1818+The response will contain a JSON object with your verification code. Paste that code below.
+152-6
shared/src/advent/challenges/day_six.rs
···11+use crate::HandleResolver;
12use crate::OAuthAgentType;
23use crate::advent::day::Day;
33-use crate::advent::{AdventChallenge, AdventError, ChallengeCheckResponse};
44+use crate::advent::{AdventChallenge, AdventError, AdventPart, ChallengeCheckResponse};
55+use crate::atrium::service_auth::{decode_and_verify_service_auth, decode_jwt_claims, extract_signing_key_bytes};
46use async_trait::async_trait;
77+use atrium_api::types::string::Did;
88+use atrium_common::resolver::Resolver;
99+use serde_json::json;
510use sqlx::PgPool;
1111+1212+pub const LXM_PART_ONE: &str = "codes.advent.challenge.verifyDay6";
1313+pub const LXM_PART_TWO: &str = "codes.advent.challenge.getDay6Code";
614715pub struct DaySix {
816 pub pool: PgPool,
917 pub oauth_client: Option<OAuthAgentType>,
1818+ pub handle_resolver: HandleResolver,
1919+ pub service_did: String,
1020}
11211222#[async_trait]
···1626 }
17271828 fn day(&self) -> Day {
1919- Day::Five
2929+ Day::Six
2030 }
21312232 fn has_part_two(&self) -> bool {
2323- false
3333+ true
2434 }
25352636 fn requires_manual_verification_part_one(&self) -> bool {
2737 true
2838 }
29394040+ fn requires_manual_verification_part_two(&self) -> bool {
4141+ true
4242+ }
4343+4444+ async fn build_additional_context(
4545+ &self,
4646+ _did: &str,
4747+ _part: &AdventPart,
4848+ _code: &str,
4949+ ) -> Result<Option<serde_json::Value>, AdventError> {
5050+ // Strip "did:web:" prefix for the domain used in URLs
5151+ let domain = self.service_did.strip_prefix("did:web:").unwrap_or(&self.service_did);
5252+ Ok(Some(json!({
5353+ "service_did": self.service_did,
5454+ "service_did_domain": domain,
5555+ "lxm_part_one": LXM_PART_ONE,
5656+ "lxm_part_two": LXM_PART_TWO,
5757+ })))
5858+ }
5959+3060 async fn check_part_one(
3161 &self,
3232- _did: String,
3333- _verification_code: Option<String>,
6262+ did: String,
6363+ verification_code: Option<String>,
3464 ) -> Result<ChallengeCheckResponse, AdventError> {
3535- todo!()
6565+ let jwt = match verification_code {
6666+ Some(code) if !code.trim().is_empty() => code.trim().to_string(),
6767+ _ => {
6868+ return Ok(ChallengeCheckResponse::Incorrect(
6969+ "Please paste your serviceAuth JWT".to_string(),
7070+ ));
7171+ }
7272+ };
7373+7474+ // Decode claims first (without signature verification) to get the issuer
7575+ let claims = match decode_jwt_claims(&jwt) {
7676+ Ok(claims) => claims,
7777+ Err(e) => {
7878+ return Ok(ChallengeCheckResponse::Incorrect(format!(
7979+ "Could not decode JWT: {e}"
8080+ )));
8181+ }
8282+ };
8383+8484+ // Verify the issuer matches the logged-in user
8585+ if claims.iss != did {
8686+ return Ok(ChallengeCheckResponse::Incorrect(format!(
8787+ "The JWT issuer (iss) '{}' does not match your DID '{}'",
8888+ claims.iss, did
8989+ )));
9090+ }
9191+9292+ // Check aud
9393+ if claims.aud != self.service_did {
9494+ return Ok(ChallengeCheckResponse::Incorrect(format!(
9595+ "The JWT audience (aud) '{}' does not match the expected value '{}'",
9696+ claims.aud, self.service_did
9797+ )));
9898+ }
9999+100100+ // Check lxm
101101+ match &claims.lxm {
102102+ Some(lxm) if lxm == LXM_PART_ONE => {}
103103+ Some(lxm) => {
104104+ return Ok(ChallengeCheckResponse::Incorrect(format!(
105105+ "The JWT lexicon method (lxm) '{}' does not match the expected value '{}'",
106106+ lxm, LXM_PART_ONE
107107+ )));
108108+ }
109109+ None => {
110110+ return Ok(ChallengeCheckResponse::Incorrect(
111111+ "The JWT is missing the lexicon method (lxm) claim".to_string(),
112112+ ));
113113+ }
114114+ }
115115+116116+ // Resolve DID document and extract signing key
117117+ let iss_did: Did = claims.iss.parse().map_err(|_| {
118118+ AdventError::ShouldNotHappen(format!("Invalid DID in JWT iss: {}", claims.iss))
119119+ })?;
120120+ let did_doc = self.handle_resolver.resolve(&iss_did).await.map_err(|err| {
121121+ log::error!("Failed to resolve DID document for {}: {}", claims.iss, err);
122122+ AdventError::ShouldNotHappen(format!("Failed to resolve DID document: {err}"))
123123+ })?;
124124+125125+ let (key_alg, key_bytes) = match extract_signing_key_bytes(&did_doc) {
126126+ Ok(result) => result,
127127+ Err(e) => {
128128+ return Ok(ChallengeCheckResponse::Incorrect(format!(
129129+ "Could not extract signing key from your DID document: {e}"
130130+ )));
131131+ }
132132+ };
133133+134134+ // Fully verify the JWT signature
135135+ match decode_and_verify_service_auth(&jwt, &key_bytes, key_alg) {
136136+ Ok(_) => Ok(ChallengeCheckResponse::Correct),
137137+ Err(e) => Ok(ChallengeCheckResponse::Incorrect(format!(
138138+ "JWT verification failed: {e}"
139139+ ))),
140140+ }
141141+ }
142142+143143+ async fn check_part_two(
144144+ &self,
145145+ did: String,
146146+ verification_code: Option<String>,
147147+ ) -> Result<ChallengeCheckResponse, AdventError> {
148148+ let submitted_code = match verification_code {
149149+ Some(code) if !code.trim().is_empty() => code.trim().to_string(),
150150+ _ => {
151151+ return Ok(ChallengeCheckResponse::Incorrect(
152152+ "Please enter the verification code from the XRPC response".to_string(),
153153+ ));
154154+ }
155155+ };
156156+157157+ let challenge = self.get_days_challenge(&did).await?;
158158+ match challenge {
159159+ None => {
160160+ log::error!("No challenge record found for day 6 for user: {did}");
161161+ Err(AdventError::ShouldNotHappen(
162162+ "Could not find challenge record".to_string(),
163163+ ))
164164+ }
165165+ Some(challenge) => {
166166+ let expected = challenge
167167+ .verification_code_two
168168+ .ok_or(AdventError::ShouldNotHappen(
169169+ "No verification code for part two".to_string(),
170170+ ))?;
171171+172172+ if submitted_code == expected {
173173+ Ok(ChallengeCheckResponse::Correct)
174174+ } else {
175175+ Ok(ChallengeCheckResponse::Incorrect(format!(
176176+ "The code '{}' is incorrect. Make sure you're using the code from the XRPC response.",
177177+ submitted_code
178178+ )))
179179+ }
180180+ }
181181+ }
36182 }
37183}
+1
shared/src/atrium/mod.rs
···22use serde::de;
3344pub mod dns_resolver;
55+pub mod service_auth;
56pub mod stores;
6778/// Safely parses an unknown record into a type. If it fails, it logs the error and returns an error.
+162
shared/src/atrium/service_auth.rs
···11+use atrium_api::did_doc::DidDocument;
22+use atrium_crypto::did::parse_multikey;
33+use atrium_crypto::verify::Verifier;
44+use atrium_crypto::Algorithm;
55+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
66+use base64::Engine;
77+use serde::Deserialize;
88+use std::fmt;
99+1010+#[derive(Debug)]
1111+pub enum ServiceAuthError {
1212+ InvalidFormat,
1313+ InvalidBase64(String),
1414+ InvalidJson(String),
1515+ UnsupportedAlgorithm(String),
1616+ Expired,
1717+ InvalidSignature(String),
1818+ MissingSigningKey,
1919+ ClaimMismatch(String),
2020+}
2121+2222+impl fmt::Display for ServiceAuthError {
2323+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2424+ match self {
2525+ Self::InvalidFormat => write!(f, "JWT must have three dot-separated parts"),
2626+ Self::InvalidBase64(msg) => write!(f, "Invalid base64url encoding: {msg}"),
2727+ Self::InvalidJson(msg) => write!(f, "Invalid JSON in JWT: {msg}"),
2828+ Self::UnsupportedAlgorithm(alg) => {
2929+ write!(f, "Unsupported algorithm '{alg}', expected ES256 or ES256K")
3030+ }
3131+ Self::Expired => write!(f, "Token has expired"),
3232+ Self::InvalidSignature(msg) => write!(f, "Invalid signature: {msg}"),
3333+ Self::MissingSigningKey => write!(f, "No signing key found in DID document"),
3434+ Self::ClaimMismatch(msg) => write!(f, "{msg}"),
3535+ }
3636+ }
3737+}
3838+3939+#[derive(Debug, Deserialize)]
4040+struct JwtHeader {
4141+ alg: String,
4242+ #[allow(dead_code)]
4343+ typ: Option<String>,
4444+}
4545+4646+#[derive(Debug, Deserialize, Clone)]
4747+pub struct ServiceAuthClaims {
4848+ pub iss: String,
4949+ pub aud: String,
5050+ #[serde(default)]
5151+ pub exp: u64,
5252+ #[serde(default)]
5353+ pub iat: Option<u64>,
5454+ #[serde(default)]
5555+ pub lxm: Option<String>,
5656+ #[serde(default)]
5757+ pub jti: Option<String>,
5858+}
5959+6060+/// Decode JWT claims without verifying the signature.
6161+/// Useful for extracting the `iss` field before resolving the DID document.
6262+pub fn decode_jwt_claims(jwt: &str) -> Result<ServiceAuthClaims, ServiceAuthError> {
6363+ let parts: Vec<&str> = jwt.trim().split('.').collect();
6464+ if parts.len() != 3 {
6565+ return Err(ServiceAuthError::InvalidFormat);
6666+ }
6767+6868+ let payload_bytes = URL_SAFE_NO_PAD
6969+ .decode(parts[1])
7070+ .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?;
7171+7272+ serde_json::from_slice(&payload_bytes)
7373+ .map_err(|e| ServiceAuthError::InvalidJson(e.to_string()))
7474+}
7575+7676+/// Fully decode and verify a serviceAuth JWT.
7777+///
7878+/// `public_key_bytes` should be the decompressed SEC1 public key bytes
7979+/// from the issuer's DID document (as returned by `extract_signing_key_bytes`).
8080+/// `key_algorithm` is the algorithm associated with the key from the DID document.
8181+pub fn decode_and_verify_service_auth(
8282+ jwt: &str,
8383+ public_key_bytes: &[u8],
8484+ key_algorithm: Algorithm,
8585+) -> Result<ServiceAuthClaims, ServiceAuthError> {
8686+ let jwt = jwt.trim();
8787+ let parts: Vec<&str> = jwt.split('.').collect();
8888+ if parts.len() != 3 {
8989+ return Err(ServiceAuthError::InvalidFormat);
9090+ }
9191+9292+ let header_bytes = URL_SAFE_NO_PAD
9393+ .decode(parts[0])
9494+ .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?;
9595+ let payload_bytes = URL_SAFE_NO_PAD
9696+ .decode(parts[1])
9797+ .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?;
9898+ let signature_bytes = URL_SAFE_NO_PAD
9999+ .decode(parts[2])
100100+ .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?;
101101+102102+ let header: JwtHeader = serde_json::from_slice(&header_bytes)
103103+ .map_err(|e| ServiceAuthError::InvalidJson(e.to_string()))?;
104104+ let claims: ServiceAuthClaims = serde_json::from_slice(&payload_bytes)
105105+ .map_err(|e| ServiceAuthError::InvalidJson(e.to_string()))?;
106106+107107+ // Validate algorithm
108108+ let jwt_algorithm = match header.alg.as_str() {
109109+ "ES256" => Algorithm::P256,
110110+ "ES256K" => Algorithm::Secp256k1,
111111+ other => return Err(ServiceAuthError::UnsupportedAlgorithm(other.to_string())),
112112+ };
113113+114114+ // The JWT algorithm must match the key's algorithm from the DID document
115115+ if jwt_algorithm != key_algorithm {
116116+ return Err(ServiceAuthError::UnsupportedAlgorithm(format!(
117117+ "JWT uses {} but DID document key is {}",
118118+ header.alg,
119119+ match key_algorithm {
120120+ Algorithm::P256 => "ES256 (P-256)",
121121+ Algorithm::Secp256k1 => "ES256K (secp256k1)",
122122+ }
123123+ )));
124124+ }
125125+126126+ // Check expiry
127127+ let now = chrono::Utc::now().timestamp() as u64;
128128+ if claims.exp > 0 && claims.exp < now {
129129+ return Err(ServiceAuthError::Expired);
130130+ }
131131+132132+ // Verify signature: signing input is the raw "{header}.{payload}" string
133133+ let signing_input = format!("{}.{}", parts[0], parts[1]);
134134+ let verifier = Verifier::new(true);
135135+ verifier
136136+ .verify(
137137+ jwt_algorithm,
138138+ public_key_bytes,
139139+ signing_input.as_bytes(),
140140+ &signature_bytes,
141141+ )
142142+ .map_err(|e| ServiceAuthError::InvalidSignature(e.to_string()))?;
143143+144144+ Ok(claims)
145145+}
146146+147147+/// Extract the signing key bytes and algorithm from a DID document.
148148+/// Returns the decompressed SEC1 public key bytes and the associated algorithm.
149149+pub fn extract_signing_key_bytes(
150150+ did_doc: &DidDocument,
151151+) -> Result<(Algorithm, Vec<u8>), ServiceAuthError> {
152152+ let method = did_doc
153153+ .get_signing_key()
154154+ .ok_or(ServiceAuthError::MissingSigningKey)?;
155155+ let multibase = method
156156+ .public_key_multibase
157157+ .as_ref()
158158+ .ok_or(ServiceAuthError::MissingSigningKey)?;
159159+ let (alg, key_bytes) =
160160+ parse_multikey(multibase).map_err(|e| ServiceAuthError::InvalidSignature(e.to_string()))?;
161161+ Ok((alg, key_bytes))
162162+}
+163-2
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;
67use axum::{
88+ Json,
79 extract::{Form, Path, State},
88- http::StatusCode,
1010+ http::{HeaderMap, StatusCode},
911 response::{IntoResponse, Redirect, Response},
1012};
1313+use serde_json::json;
1114use shared::advent::challenges::day_five::DayFive;
1215use shared::advent::challenges::day_four::DayFour;
1313-use shared::advent::challenges::day_six::DaySix;
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,
1919+};
1420use shared::{
1521 OAuthAgentType, OAuthClientType,
1622 advent::ChallengeCheckResponse,
···5460 Day::Six => Ok(Box::new(DaySix {
5561 pool: state.postgres_pool,
5662 oauth_client,
6363+ handle_resolver: state.handle_resolver.clone(),
6464+ service_did: format!(
6565+ "did:web:{}",
6666+ std::env::var("OAUTH_HOST").unwrap_or_default()
6767+ ),
5768 })),
5869 _ => Err(AdventError::InvalidDay(0)), // Day::Three => {}
5970 // Day::Four => {}
···511522512523 Ok(Redirect::to("/day/5"))
513524}
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+}