An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(relay): OAuth server metadata endpoint — GET /.well-known/oauth-authorization-server (MM-75)

Implements RFC 8414 OAuth 2.0 Authorization Server Metadata discovery so
Bluesky clients can find the relay's OAuth endpoints without hardcoding paths.

- New route: GET /.well-known/oauth-authorization-server returns JSON with
issuer, authorization_endpoint, token_endpoint, pushed_authorization_request_endpoint,
jwks_uri, and AT Protocol-required fields (dpop_signing_alg_values_supported,
token_endpoint_auth_methods_supported: ["none"])
- New db/oauth.rs: SQLite storage adapter (register_oauth_client, get_oauth_client)
against the existing oauth_clients table from Wave 1 schema
- Bruno request file added (seq: 12)
- Fix two pre-existing clippy lints in auth/mod.rs and create_did.rs surfaced
by the updated toolchain; cargo fmt applied across relay crate

atproto-oauth-axum (0.14.3) was evaluated but requires axum 0.8 (relay is on
0.7) and is an OAuth client crate, not a server crate — tracked as MM-154.

authored by

Malpercio and committed by
Tangled
6637ca9e 2c693a63

+512 -67
+15
bruno/oauth_server_metadata.bru
··· 1 + meta { 2 + name: OAuth Server Metadata 3 + type: http 4 + seq: 12 5 + } 6 + 7 + get { 8 + url: {{baseUrl}}/.well-known/oauth-authorization-server 9 + body: none 10 + auth: none 11 + } 12 + 13 + vars:pre-request { 14 + baseUrl: http://localhost:8080 15 + }
+5
crates/relay/src/app.rs
··· 22 22 use crate::routes::describe_server::describe_server; 23 23 use crate::routes::get_relay_signing_key::get_relay_signing_key; 24 24 use crate::routes::health::health; 25 + use crate::routes::oauth_server_metadata::oauth_server_metadata; 25 26 use crate::routes::register_device::register_device; 26 27 use crate::routes::resolve_handle::resolve_handle_handler; 27 28 use crate::well_known::WellKnownResolver; ··· 107 108 /// listener — callers can use `tower::ServiceExt::oneshot` to drive requests in tests. 108 109 pub fn app(state: AppState) -> Router { 109 110 Router::new() 111 + .route( 112 + "/.well-known/oauth-authorization-server", 113 + get(oauth_server_metadata), 114 + ) 110 115 .route("/xrpc/_health", get(health)) 111 116 .route( 112 117 "/xrpc/com.atproto.server.describeServer",
+97 -41
crates/relay/src/auth/mod.rs
··· 126 126 .and_then(|v| { 127 127 v.to_str() 128 128 .inspect_err(|_| { 129 - tracing::warn!( 130 - "DPoP header contains non-UTF-8 bytes; treating as absent" 131 - ); 129 + tracing::warn!("DPoP header contains non-UTF-8 bytes; treating as absent"); 132 130 }) 133 131 .ok() 134 132 }) ··· 215 213 const BEARER_LEN: usize = 7; // "Bearer ".len() — scheme name + single SP 216 214 if !auth_value 217 215 .get(..BEARER_LEN) 218 - .map_or(false, |s| s.eq_ignore_ascii_case("Bearer ")) 216 + .is_some_and(|s| s.eq_ignore_ascii_case("Bearer ")) 219 217 { 220 218 return Err(ApiError::new( 221 219 ErrorCode::AuthenticationRequired, ··· 268 266 "com.atproto.access" => Ok(AuthScope::Access), 269 267 "com.atproto.refresh" => Ok(AuthScope::Refresh), 270 268 "com.atproto.appPass" => Ok(AuthScope::AppPass), 271 - _ => Err(ApiError::new(ErrorCode::InvalidToken, "unrecognised token scope")), 269 + _ => Err(ApiError::new( 270 + ErrorCode::InvalidToken, 271 + "unrecognised token scope", 272 + )), 272 273 } 273 274 } 274 275 ··· 362 363 // Require `jti` for replay protection (existence check only — full deduplication 363 364 // per RFC 9449 §11.1 requires a server-side nonce store, not yet implemented). 364 365 if dpop_claims.jti.is_empty() { 365 - return Err(ApiError::new(ErrorCode::InvalidToken, "DPoP proof missing jti")); 366 + return Err(ApiError::new( 367 + ErrorCode::InvalidToken, 368 + "DPoP proof missing jti", 369 + )); 366 370 } 367 371 368 372 // Validate `htm` (HTTP method). ··· 409 413 // client sends iat = i64::MIN (debug panic; release wraparound bypass). 410 414 let diff = (now as i128) - (dpop_claims.iat as i128); 411 415 if diff.unsigned_abs() > 60 { 412 - return Err(ApiError::new(ErrorCode::InvalidToken, "DPoP proof is stale")); 416 + return Err(ApiError::new( 417 + ErrorCode::InvalidToken, 418 + "DPoP proof is stale", 419 + )); 413 420 } 414 421 415 422 Ok(()) ··· 566 573 "jti": uuid::Uuid::new_v4().to_string(), 567 574 }); 568 575 569 - let hdr_b64 = 570 - URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 571 - let pay_b64 = 572 - URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 576 + let hdr_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 577 + let pay_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 573 578 let signing_input = format!("{hdr_b64}.{pay_b64}"); 574 579 575 580 let sig: Signature = key.sign(signing_input.as_bytes()); ··· 596 601 if let Some(t) = token { 597 602 builder = builder.header("Authorization", format!("Bearer {t}")); 598 603 } 599 - app.oneshot(builder.body(Body::empty()).unwrap()).await.unwrap() 604 + app.oneshot(builder.body(Body::empty()).unwrap()) 605 + .await 606 + .unwrap() 600 607 } 601 608 602 609 async fn json_body(resp: axum::response::Response) -> serde_json::Value { ··· 683 690 ); 684 691 let resp = get_protected(protected_app(state), Some(&token)).await; 685 692 assert_eq!(resp.status(), StatusCode::OK); 686 - let text = 687 - String::from_utf8(axum::body::to_bytes(resp.into_body(), 4096).await.unwrap().to_vec()) 688 - .unwrap(); 693 + let text = String::from_utf8( 694 + axum::body::to_bytes(resp.into_body(), 4096) 695 + .await 696 + .unwrap() 697 + .to_vec(), 698 + ) 699 + .unwrap(); 689 700 assert!(text.contains("did=did:plc:alice")); 690 701 assert!(text.contains("scope=Access")); 691 702 } ··· 693 704 #[tokio::test] 694 705 async fn valid_refresh_token_extracts_refresh_scope() { 695 706 let state = test_state().await; 696 - let token = mint_token("did:plc:alice", "com.atproto.refresh", 3600, &state.jwt_secret, None); 707 + let token = mint_token( 708 + "did:plc:alice", 709 + "com.atproto.refresh", 710 + 3600, 711 + &state.jwt_secret, 712 + None, 713 + ); 697 714 let resp = get_protected(protected_app(state), Some(&token)).await; 698 715 assert_eq!(resp.status(), StatusCode::OK); 699 - let text = 700 - String::from_utf8(axum::body::to_bytes(resp.into_body(), 4096).await.unwrap().to_vec()) 701 - .unwrap(); 716 + let text = String::from_utf8( 717 + axum::body::to_bytes(resp.into_body(), 4096) 718 + .await 719 + .unwrap() 720 + .to_vec(), 721 + ) 722 + .unwrap(); 702 723 assert!(text.contains("scope=Refresh")); 703 724 } 704 725 705 726 #[tokio::test] 706 727 async fn valid_app_pass_token_extracts_app_pass_scope() { 707 728 let state = test_state().await; 708 - let token = mint_token("did:plc:alice", "com.atproto.appPass", 3600, &state.jwt_secret, None); 729 + let token = mint_token( 730 + "did:plc:alice", 731 + "com.atproto.appPass", 732 + 3600, 733 + &state.jwt_secret, 734 + None, 735 + ); 709 736 let resp = get_protected(protected_app(state), Some(&token)).await; 710 737 assert_eq!(resp.status(), StatusCode::OK); 711 - let text = 712 - String::from_utf8(axum::body::to_bytes(resp.into_body(), 4096).await.unwrap().to_vec()) 713 - .unwrap(); 738 + let text = String::from_utf8( 739 + axum::body::to_bytes(resp.into_body(), 4096) 740 + .await 741 + .unwrap() 742 + .to_vec(), 743 + ) 744 + .unwrap(); 714 745 assert!(text.contains("scope=AppPass")); 715 746 } 716 747 ··· 719 750 #[tokio::test] 720 751 async fn unknown_scope_returns_401_invalid_token() { 721 752 let state = test_state().await; 722 - let token = mint_token("did:plc:user", "com.example.unknown", 3600, &state.jwt_secret, None); 753 + let token = mint_token( 754 + "did:plc:user", 755 + "com.example.unknown", 756 + 3600, 757 + &state.jwt_secret, 758 + None, 759 + ); 723 760 let resp = get_protected(protected_app(state), Some(&token)).await; 724 761 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 725 762 let json = json_body(resp).await; ··· 734 771 let base = test_state().await; 735 772 let mut config = (*base.config).clone(); 736 773 config.server_did = Some("did:plc:server".to_string()); 737 - let state = AppState { config: Arc::new(config), ..base }; 774 + let state = AppState { 775 + config: Arc::new(config), 776 + ..base 777 + }; 738 778 739 779 // mint_token encodes aud = "did:plc:test" — wrong for did:plc:server 740 - let token = mint_token("did:plc:user", "com.atproto.access", 3600, &state.jwt_secret, None); 780 + let token = mint_token( 781 + "did:plc:user", 782 + "com.atproto.access", 783 + 3600, 784 + &state.jwt_secret, 785 + None, 786 + ); 741 787 let resp = get_protected(protected_app(state), Some(&token)).await; 742 788 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 743 789 let json = json_body(resp).await; ··· 775 821 // decode and never reached the binding check). 776 822 let state = test_state().await; 777 823 let dpop_key = SigningKey::random(&mut OsRng); 778 - let token = mint_token("did:plc:user", "com.atproto.access", 3600, &state.jwt_secret, None); 824 + let token = mint_token( 825 + "did:plc:user", 826 + "com.atproto.access", 827 + 3600, 828 + &state.jwt_secret, 829 + None, 830 + ); 779 831 let dpop_proof = make_dpop_proof( 780 832 &dpop_key, 781 833 "GET", ··· 957 1009 "iat": now_secs() as i64, 958 1010 "jti": "test-jti", 959 1011 }); 960 - let hdr_b64 = 961 - URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 962 - let pay_b64 = 963 - URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 1012 + let hdr_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 1013 + let pay_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 964 1014 let sig: Signature = dpop_key.sign(format!("{hdr_b64}.{pay_b64}").as_bytes()); 965 - let dpop_proof = 966 - format!("{hdr_b64}.{pay_b64}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8])); 1015 + let dpop_proof = format!( 1016 + "{hdr_b64}.{pay_b64}.{}", 1017 + URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8]) 1018 + ); 967 1019 968 1020 let req = Request::builder() 969 1021 .uri("/protected") ··· 999 1051 "iat": now_secs() as i64, 1000 1052 "jti": "", 1001 1053 }); 1002 - let hdr_b64 = 1003 - URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 1004 - let pay_b64 = 1005 - URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 1054 + let hdr_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 1055 + let pay_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 1006 1056 let sig: Signature = dpop_key.sign(format!("{hdr_b64}.{pay_b64}").as_bytes()); 1007 - let dpop_proof = 1008 - format!("{hdr_b64}.{pay_b64}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8])); 1057 + let dpop_proof = format!( 1058 + "{hdr_b64}.{pay_b64}.{}", 1059 + URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8]) 1060 + ); 1009 1061 1010 1062 let req = Request::builder() 1011 1063 .uri("/protected") ··· 1175 1227 let thumb = jwk_thumbprint(&jwk).unwrap(); 1176 1228 assert_eq!(thumb.len(), 43, "thumbprint must be 43 base64url chars"); 1177 1229 assert!( 1178 - thumb.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_'), 1230 + thumb 1231 + .chars() 1232 + .all(|c| c.is_alphanumeric() || c == '-' || c == '_'), 1179 1233 "thumbprint must be base64url" 1180 1234 ); 1181 1235 // Stable regression guard — verified against this implementation. ··· 1193 1247 }); 1194 1248 let thumb = jwk_thumbprint(&jwk).unwrap(); 1195 1249 assert_eq!(thumb.len(), 43); 1196 - assert!(thumb.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')); 1250 + assert!(thumb 1251 + .chars() 1252 + .all(|c| c.is_alphanumeric() || c == '-' || c == '_')); 1197 1253 // Stable regression guard. 1198 1254 assert_eq!(thumb, "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"); 1199 1255 }
+2
crates/relay/src/db/mod.rs
··· 1 + pub mod oauth; 2 + 1 3 use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; 2 4 use sqlx::SqlitePool; 3 5 use std::str::FromStr;
+128
crates/relay/src/db/oauth.rs
··· 1 + // pattern: Functional Core (pure data access — no business logic) 2 + // 3 + // Storage adapter for OAuth server-side state in the `oauth_clients` table. 4 + // Future tickets add authorization code and token functions as the full OAuth 5 + // flow is implemented. 6 + 7 + use sqlx::SqlitePool; 8 + 9 + /// A registered OAuth client row from the `oauth_clients` table. 10 + /// 11 + /// `client_metadata` is stored as a raw JSON string (RFC 7591 client metadata). 12 + /// Callers are responsible for serializing/deserializing the JSON. 13 + // Not yet wired to a handler — will be used when the OAuth authorization flow is implemented. 14 + #[allow(dead_code)] 15 + pub struct OAuthClientRow { 16 + pub client_id: String, 17 + pub client_metadata: String, 18 + pub created_at: String, 19 + } 20 + 21 + /// Register a new OAuth client. 22 + /// 23 + /// `client_id` is an HTTPS URL (the client's metadata document URL per AT Protocol OAuth spec). 24 + /// `client_metadata` is a JSON string conforming to RFC 7591 client metadata. 25 + /// 26 + /// Returns `sqlx::Error` on failure. Callers should use `crate::db::is_unique_violation` 27 + /// to detect duplicate `client_id` conflicts. 28 + // Not yet wired to a handler — will be used when the OAuth authorization flow is implemented. 29 + #[allow(dead_code)] 30 + pub async fn register_oauth_client( 31 + pool: &SqlitePool, 32 + client_id: &str, 33 + client_metadata: &str, 34 + ) -> Result<(), sqlx::Error> { 35 + sqlx::query( 36 + "INSERT INTO oauth_clients (client_id, client_metadata, created_at) \ 37 + VALUES (?, ?, datetime('now'))", 38 + ) 39 + .bind(client_id) 40 + .bind(client_metadata) 41 + .execute(pool) 42 + .await?; 43 + Ok(()) 44 + } 45 + 46 + /// Look up a registered OAuth client by `client_id`. Returns `None` if not found. 47 + // Not yet wired to a handler — will be used when the OAuth authorization flow is implemented. 48 + #[allow(dead_code)] 49 + pub async fn get_oauth_client( 50 + pool: &SqlitePool, 51 + client_id: &str, 52 + ) -> Result<Option<OAuthClientRow>, sqlx::Error> { 53 + let row: Option<(String, String, String)> = sqlx::query_as( 54 + "SELECT client_id, client_metadata, created_at FROM oauth_clients WHERE client_id = ?", 55 + ) 56 + .bind(client_id) 57 + .fetch_optional(pool) 58 + .await?; 59 + 60 + Ok( 61 + row.map(|(client_id, client_metadata, created_at)| OAuthClientRow { 62 + client_id, 63 + client_metadata, 64 + created_at, 65 + }), 66 + ) 67 + } 68 + 69 + #[cfg(test)] 70 + mod tests { 71 + use super::*; 72 + use crate::db::{is_unique_violation, open_pool, run_migrations}; 73 + 74 + async fn test_pool() -> SqlitePool { 75 + let pool = open_pool("sqlite::memory:").await.unwrap(); 76 + run_migrations(&pool).await.unwrap(); 77 + pool 78 + } 79 + 80 + #[tokio::test] 81 + async fn register_and_retrieve_oauth_client() { 82 + let pool = test_pool().await; 83 + let client_id = "https://app.example.com/client-metadata.json"; 84 + let metadata = r#"{"redirect_uris":["https://app.example.com/callback"]}"#; 85 + 86 + register_oauth_client(&pool, client_id, metadata) 87 + .await 88 + .unwrap(); 89 + 90 + let row = get_oauth_client(&pool, client_id) 91 + .await 92 + .unwrap() 93 + .expect("client should exist after registration"); 94 + 95 + assert_eq!(row.client_id, client_id); 96 + assert_eq!(row.client_metadata, metadata); 97 + assert!(!row.created_at.is_empty()); 98 + } 99 + 100 + #[tokio::test] 101 + async fn get_oauth_client_returns_none_for_unknown_client() { 102 + let pool = test_pool().await; 103 + let result = get_oauth_client(&pool, "https://unknown.example.com/client") 104 + .await 105 + .unwrap(); 106 + assert!(result.is_none()); 107 + } 108 + 109 + #[tokio::test] 110 + async fn register_duplicate_client_id_is_unique_violation() { 111 + let pool = test_pool().await; 112 + let client_id = "https://app.example.com/client-metadata.json"; 113 + let metadata = r#"{"redirect_uris":["https://app.example.com/callback"]}"#; 114 + 115 + register_oauth_client(&pool, client_id, metadata) 116 + .await 117 + .unwrap(); 118 + 119 + let err = register_oauth_client(&pool, client_id, metadata) 120 + .await 121 + .unwrap_err(); 122 + 123 + assert!( 124 + is_unique_violation(&err), 125 + "duplicate client_id should be a unique violation" 126 + ); 127 + } 128 + }
+51 -26
crates/relay/src/routes/create_did.rs
··· 147 147 db: &sqlx::SqlitePool, 148 148 account_id: &str, 149 149 ) -> Result<PendingAccount, ApiError> { 150 - let row: (String, Option<String>, String, Option<String>, Option<String>, Option<String>) = 151 - sqlx::query_as( 152 - "SELECT handle, pending_did, email, pending_share_1, pending_share_2, pending_share_3 \ 150 + let row: ( 151 + String, 152 + Option<String>, 153 + String, 154 + Option<String>, 155 + Option<String>, 156 + Option<String>, 157 + ) = sqlx::query_as( 158 + "SELECT handle, pending_did, email, pending_share_1, pending_share_2, pending_share_3 \ 153 159 FROM pending_accounts WHERE id = ?", 154 - ) 155 - .bind(account_id) 156 - .fetch_optional(db) 157 - .await 158 - .map_err(|e| { 159 - tracing::error!(error = %e, "failed to query pending account"); 160 - ApiError::new(ErrorCode::InternalError, "failed to load account") 161 - })? 162 - .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 160 + ) 161 + .bind(account_id) 162 + .fetch_optional(db) 163 + .await 164 + .map_err(|e| { 165 + tracing::error!(error = %e, "failed to query pending account"); 166 + ApiError::new(ErrorCode::InternalError, "failed to load account") 167 + })? 168 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 163 169 Ok(PendingAccount { 164 170 handle: row.0, 165 171 pending_did: row.1, ··· 263 269 } 264 270 tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, reusing shares, skipping plc.directory"); 265 271 let s1 = pending.pending_share_1.clone().ok_or_else(|| { 266 - tracing::error!("retry: pending_share_1 is NULL; shares were not stored on first attempt"); 267 - ApiError::new(ErrorCode::InternalError, "retry: missing shares from first attempt") 272 + tracing::error!( 273 + "retry: pending_share_1 is NULL; shares were not stored on first attempt" 274 + ); 275 + ApiError::new( 276 + ErrorCode::InternalError, 277 + "retry: missing shares from first attempt", 278 + ) 268 279 })?; 269 280 let s2 = pending.pending_share_2.clone().ok_or_else(|| { 270 - tracing::error!("retry: pending_share_2 is NULL; shares were not stored on first attempt"); 271 - ApiError::new(ErrorCode::InternalError, "retry: missing shares from first attempt") 281 + tracing::error!( 282 + "retry: pending_share_2 is NULL; shares were not stored on first attempt" 283 + ); 284 + ApiError::new( 285 + ErrorCode::InternalError, 286 + "retry: missing shares from first attempt", 287 + ) 272 288 })?; 273 289 let s3 = pending.pending_share_3.clone().ok_or_else(|| { 274 - tracing::error!("retry: pending_share_3 is NULL; shares were not stored on first attempt"); 275 - ApiError::new(ErrorCode::InternalError, "retry: missing shares from first attempt") 290 + tracing::error!( 291 + "retry: pending_share_3 is NULL; shares were not stored on first attempt" 292 + ); 293 + ApiError::new( 294 + ErrorCode::InternalError, 295 + "retry: missing shares from first attempt", 296 + ) 276 297 })?; 277 298 return Ok((true, s1, s2, s3)); 278 299 } ··· 339 360 let mut secret = Zeroizing::new([0u8; 32]); 340 361 OsRng.try_fill_bytes(secret.as_mut()).map_err(|e| { 341 362 tracing::error!(error = %e, "OS RNG unavailable during recovery share generation"); 342 - ApiError::new(ErrorCode::InternalError, "failed to generate recovery secret") 363 + ApiError::new( 364 + ErrorCode::InternalError, 365 + "failed to generate recovery secret", 366 + ) 343 367 })?; 344 368 345 - let [s1, s2, s3] = crypto::split_secret(&*secret).map_err(|e| { 369 + let [s1, s2, s3] = crypto::split_secret(&secret).map_err(|e| { 346 370 tracing::error!(error = %e, "shamir split failed"); 347 371 ApiError::new(ErrorCode::InternalError, "failed to split recovery secret") 348 372 })?; ··· 790 814 ); 791 815 792 816 // accounts row with correct did, email; password_hash IS NULL; recovery_share persisted. 793 - let row: Option<(String, Option<String>, Option<String>)> = 794 - sqlx::query_as("SELECT email, password_hash, recovery_share FROM accounts WHERE did = ?") 795 - .bind(did) 796 - .fetch_optional(&db) 797 - .await 798 - .unwrap(); 817 + let row: Option<(String, Option<String>, Option<String>)> = sqlx::query_as( 818 + "SELECT email, password_hash, recovery_share FROM accounts WHERE did = ?", 819 + ) 820 + .bind(did) 821 + .fetch_optional(&db) 822 + .await 823 + .unwrap(); 799 824 let (email, password_hash, recovery_share) = row.expect("accounts row should exist"); 800 825 assert!(email.contains("alice"), "email should match test account"); 801 826 assert!(
+1
crates/relay/src/routes/mod.rs
··· 8 8 pub mod describe_server; 9 9 pub mod get_relay_signing_key; 10 10 pub mod health; 11 + pub mod oauth_server_metadata; 11 12 pub mod register_device; 12 13 pub mod resolve_handle; 13 14
+213
crates/relay/src/routes/oauth_server_metadata.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: public URL from config 4 + // Processes: none (response shape is fixed by RFC 8414 and AT Protocol OAuth spec) 5 + // Returns: JSON matching the OAuth 2.0 Authorization Server Metadata format (RFC 8414) 6 + 7 + use axum::{ 8 + extract::State, 9 + response::{IntoResponse, Json}, 10 + }; 11 + use serde::Serialize; 12 + 13 + use crate::app::AppState; 14 + 15 + /// RFC 8414 OAuth 2.0 Authorization Server Metadata response. 16 + /// 17 + /// Field names are snake_case per the OAuth spec — intentionally different from the 18 + /// camelCase used by XRPC/AT Protocol Lexicon endpoints in this codebase. 19 + /// 20 + /// AT Protocol OAuth extensions: 21 + /// - `dpop_signing_alg_values_supported`: signals that DPoP (RFC 9449) is required. 22 + /// - `token_endpoint_auth_methods_supported: ["none"]`: public clients only — no client secrets. 23 + #[derive(Serialize)] 24 + pub struct OAuthServerMetadata { 25 + issuer: String, 26 + authorization_endpoint: String, 27 + token_endpoint: String, 28 + pushed_authorization_request_endpoint: String, 29 + jwks_uri: String, 30 + response_types_supported: Vec<String>, 31 + grant_types_supported: Vec<String>, 32 + code_challenge_methods_supported: Vec<String>, 33 + dpop_signing_alg_values_supported: Vec<String>, 34 + token_endpoint_auth_methods_supported: Vec<String>, 35 + } 36 + 37 + pub async fn oauth_server_metadata(State(state): State<AppState>) -> impl IntoResponse { 38 + let base = state.config.public_url.trim_end_matches('/'); 39 + Json(OAuthServerMetadata { 40 + issuer: base.to_string(), 41 + authorization_endpoint: format!("{base}/oauth/authorize"), 42 + token_endpoint: format!("{base}/oauth/token"), 43 + pushed_authorization_request_endpoint: format!("{base}/oauth/par"), 44 + jwks_uri: format!("{base}/oauth/jwks.json"), 45 + response_types_supported: vec!["code".to_string()], 46 + grant_types_supported: vec![ 47 + "authorization_code".to_string(), 48 + "refresh_token".to_string(), 49 + ], 50 + code_challenge_methods_supported: vec!["S256".to_string()], 51 + dpop_signing_alg_values_supported: vec!["ES256".to_string()], 52 + token_endpoint_auth_methods_supported: vec!["none".to_string()], 53 + }) 54 + } 55 + 56 + #[cfg(test)] 57 + mod tests { 58 + use axum::{ 59 + body::Body, 60 + http::{Request, StatusCode}, 61 + }; 62 + use tower::ServiceExt; 63 + 64 + use crate::app::{app, test_state}; 65 + 66 + async fn metadata_json() -> serde_json::Value { 67 + let response = app(test_state().await) 68 + .oneshot( 69 + Request::builder() 70 + .uri("/.well-known/oauth-authorization-server") 71 + .body(Body::empty()) 72 + .unwrap(), 73 + ) 74 + .await 75 + .unwrap(); 76 + assert_eq!(response.status(), StatusCode::OK); 77 + let body = axum::body::to_bytes(response.into_body(), 4096) 78 + .await 79 + .unwrap(); 80 + serde_json::from_slice(&body).unwrap() 81 + } 82 + 83 + #[tokio::test] 84 + async fn returns_200_with_json_content_type() { 85 + let response = app(test_state().await) 86 + .oneshot( 87 + Request::builder() 88 + .uri("/.well-known/oauth-authorization-server") 89 + .body(Body::empty()) 90 + .unwrap(), 91 + ) 92 + .await 93 + .unwrap(); 94 + 95 + assert_eq!(response.status(), StatusCode::OK); 96 + assert_eq!( 97 + response.headers().get("content-type").unwrap(), 98 + "application/json" 99 + ); 100 + } 101 + 102 + #[tokio::test] 103 + async fn issuer_matches_public_url() { 104 + let json = metadata_json().await; 105 + assert_eq!(json["issuer"], "https://test.example.com"); 106 + } 107 + 108 + #[tokio::test] 109 + async fn endpoints_use_public_url_as_base() { 110 + let json = metadata_json().await; 111 + assert_eq!( 112 + json["authorization_endpoint"], 113 + "https://test.example.com/oauth/authorize" 114 + ); 115 + assert_eq!( 116 + json["token_endpoint"], 117 + "https://test.example.com/oauth/token" 118 + ); 119 + assert_eq!( 120 + json["pushed_authorization_request_endpoint"], 121 + "https://test.example.com/oauth/par" 122 + ); 123 + assert_eq!(json["jwks_uri"], "https://test.example.com/oauth/jwks.json"); 124 + } 125 + 126 + #[tokio::test] 127 + async fn response_types_contains_code() { 128 + let json = metadata_json().await; 129 + assert!(json["response_types_supported"] 130 + .as_array() 131 + .unwrap() 132 + .iter() 133 + .any(|v| v == "code")); 134 + } 135 + 136 + #[tokio::test] 137 + async fn grant_types_include_authorization_code_and_refresh_token() { 138 + let json = metadata_json().await; 139 + let grants = json["grant_types_supported"].as_array().unwrap(); 140 + assert!(grants.iter().any(|v| v == "authorization_code")); 141 + assert!(grants.iter().any(|v| v == "refresh_token")); 142 + } 143 + 144 + #[tokio::test] 145 + async fn dpop_signing_alg_includes_es256() { 146 + let json = metadata_json().await; 147 + assert!(json["dpop_signing_alg_values_supported"] 148 + .as_array() 149 + .unwrap() 150 + .iter() 151 + .any(|v| v == "ES256")); 152 + } 153 + 154 + #[tokio::test] 155 + async fn token_endpoint_auth_method_is_none() { 156 + let json = metadata_json().await; 157 + assert!(json["token_endpoint_auth_methods_supported"] 158 + .as_array() 159 + .unwrap() 160 + .iter() 161 + .any(|v| v == "none")); 162 + } 163 + 164 + #[tokio::test] 165 + async fn pkce_method_is_s256() { 166 + let json = metadata_json().await; 167 + assert!(json["code_challenge_methods_supported"] 168 + .as_array() 169 + .unwrap() 170 + .iter() 171 + .any(|v| v == "S256")); 172 + } 173 + 174 + #[tokio::test] 175 + async fn trailing_slash_in_public_url_does_not_double_slash_endpoints() { 176 + use crate::app::AppState; 177 + use std::sync::Arc; 178 + 179 + let base = test_state().await; 180 + let mut config = (*base.config).clone(); 181 + config.public_url = "https://pds.example.com/".to_string(); 182 + let state = AppState { 183 + config: Arc::new(config), 184 + db: base.db, 185 + http_client: base.http_client, 186 + dns_provider: base.dns_provider, 187 + txt_resolver: base.txt_resolver, 188 + well_known_resolver: base.well_known_resolver, 189 + jwt_secret: base.jwt_secret, 190 + }; 191 + 192 + let response = app(state) 193 + .oneshot( 194 + Request::builder() 195 + .uri("/.well-known/oauth-authorization-server") 196 + .body(Body::empty()) 197 + .unwrap(), 198 + ) 199 + .await 200 + .unwrap(); 201 + 202 + let body = axum::body::to_bytes(response.into_body(), 4096) 203 + .await 204 + .unwrap(); 205 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 206 + 207 + // public_url with trailing slash must not produce "...com//oauth/..." 208 + assert_eq!( 209 + json["authorization_endpoint"], 210 + "https://pds.example.com/oauth/authorize" 211 + ); 212 + } 213 + }