this repo has no description

Per-user JWT stuffs

+1 -1
.gitignore
··· 1 1 /target 2 - src_old 3 2 .sqlx 4 3 5 4 .env 6 5 6 + reference-pds/
+4 -1
Cargo.lock
··· 450 450 dependencies = [ 451 451 "anyhow", 452 452 "axum", 453 + "base64 0.22.1", 453 454 "bcrypt", 454 455 "bytes", 455 456 "chrono", ··· 459 460 "jacquard-axum", 460 461 "jacquard-repo", 461 462 "jsonwebtoken", 463 + "k256", 462 464 "multihash", 465 + "rand 0.8.5", 463 466 "reqwest", 464 467 "serde", 465 468 "serde_ipld_dagcbor", ··· 973 976 checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 974 977 dependencies = [ 975 978 "data-encoding", 976 - "syn 2.0.111", 979 + "syn 1.0.109", 977 980 ] 978 981 979 982 [[package]]
+3
Cargo.toml
··· 6 6 [dependencies] 7 7 anyhow = "1.0.100" 8 8 axum = "0.8.7" 9 + base64 = "0.22.1" 9 10 bcrypt = "0.17.1" 10 11 bytes = "1.11.0" 11 12 chrono = { version = "0.4.42", features = ["serde"] } ··· 15 16 jacquard-axum = "0.9.2" 16 17 jacquard-repo = "0.9.2" 17 18 jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } 19 + k256 = { version = "0.13.3", features = ["ecdsa", "pem", "pkcs8"] } 18 20 multihash = "0.19.3" 21 + rand = "0.8.5" 19 22 reqwest = { version = "0.12.24", features = ["json"] } 20 23 serde = { version = "1.0.228", features = ["derive"] } 21 24 serde_ipld_dagcbor = "0.6.4"
+75 -9
TODO.md
··· 2 2 3 3 Lewis' corrected big boy todofile 4 4 5 - ## 1. Server Infrastructure & Proxying 5 + ## Server Infrastructure & Proxying 6 6 - [x] Health Check 7 7 - [x] Implement `GET /health` endpoint (returns "OK"). 8 8 - [x] Server Description ··· 11 11 - [x] Implement strict forwarding for all `app.bsky.*` and `chat.bsky.*` requests to an appview. 12 12 - [x] Forward Auth headers correctly. 13 13 - [x] Handle AppView errors/timeouts gracefully. 14 + - [ ] Implement Read-After-Write (RAW) consistency (Local Overlay) for proxied requests (merge local unindexed records). 14 15 15 - ## 2. Authentication & Account Management (`com.atproto.server`) 16 + ## Authentication & Account Management (`com.atproto.server`) 16 17 - [x] Account Creation 17 18 - [x] Implement `com.atproto.server.createAccount`. 18 19 - [x] Validate handle format (reject invalid characters). ··· 25 26 - [x] Implement `com.atproto.server.getSession`. 26 27 - [x] Implement `com.atproto.server.refreshSession`. 27 28 - [x] Implement `com.atproto.server.deleteSession` (Logout). 29 + - [ ] Implement `com.atproto.server.activateAccount`. 30 + - [ ] Implement `com.atproto.server.checkAccountStatus`. 31 + - [ ] Implement `com.atproto.server.confirmEmail`. 32 + - [ ] Implement `com.atproto.server.createAppPassword`. 33 + - [ ] Implement `com.atproto.server.createInviteCode`. 34 + - [ ] Implement `com.atproto.server.createInviteCodes`. 35 + - [ ] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`. 36 + - [ ] Implement `com.atproto.server.getAccountInviteCodes`. 37 + - [ ] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). 38 + - [ ] Implement `com.atproto.server.listAppPasswords`. 39 + - [ ] Implement `com.atproto.server.requestAccountDelete`. 40 + - [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. 41 + - [ ] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. 42 + - [ ] Implement `com.atproto.server.reserveSigningKey`. 43 + - [ ] Implement `com.atproto.server.revokeAppPassword`. 44 + - [ ] Implement `com.atproto.server.updateEmail`. 28 45 29 - ## 3. Repository Operations (`com.atproto.repo`) 46 + ## Repository Operations (`com.atproto.repo`) 30 47 - [ ] Record CRUD 31 48 - [ ] Implement `com.atproto.repo.createRecord`. 32 49 - [ ] Validate schema against Lexicon (just structure, not complex logic). ··· 38 55 - [ ] Implement `com.atproto.repo.deleteRecord`. 39 56 - [ ] Implement `com.atproto.repo.listRecords`. 40 57 - [ ] Implement `com.atproto.repo.describeRepo`. 58 + - [ ] Implement `com.atproto.repo.applyWrites` (Batch writes). 59 + - [ ] Implement `com.atproto.repo.importRepo` (Migration). 60 + - [ ] Implement `com.atproto.repo.listMissingBlobs`. 41 61 - [ ] Blob Management 42 62 - [ ] Implement `com.atproto.repo.uploadBlob`. 43 63 - [ ] Store blob (S3). 44 64 - [ ] return `blob` ref (CID + MimeType). 45 65 46 - ## 4. Sync & Federation (`com.atproto.sync`) 66 + ## Sync & Federation (`com.atproto.sync`) 47 67 - [ ] The Firehose (WebSocket) 48 68 - [ ] Implement `com.atproto.sync.subscribeRepos`. 49 69 - [ ] Broadcast real-time commit events. ··· 53 73 - [ ] Implement `com.atproto.sync.getBlocks` (Return specific blocks via CIDs). 54 74 - [ ] Implement `com.atproto.sync.getLatestCommit`. 55 75 - [ ] Implement `com.atproto.sync.getRecord` (Sync version, distinct from repo.getRecord). 76 + - [ ] Implement `com.atproto.sync.getRepoStatus`. 77 + - [ ] Implement `com.atproto.sync.listRepos`. 78 + - [ ] Implement `com.atproto.sync.notifyOfUpdate`. 56 79 - [ ] Blob Sync 57 80 - [ ] Implement `com.atproto.sync.getBlob`. 58 81 - [ ] Implement `com.atproto.sync.listBlobs`. 59 82 - [ ] Crawler Interaction 60 83 - [ ] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us). 61 84 62 - ## 5. Identity (`com.atproto.identity`) 85 + ## Identity (`com.atproto.identity`) 63 86 - [ ] Resolution 64 87 - [ ] Implement `com.atproto.identity.resolveHandle` (Can be internal or proxy to PLC). 88 + - [ ] Implement `com.atproto.identity.updateHandle`. 89 + - [ ] Implement `com.atproto.identity.submitPlcOperation` / `signPlcOperation` / `requestPlcOperationSignature`. 90 + - [ ] Implement `com.atproto.identity.getRecommendedDidCredentials`. 65 91 - [ ] Implement `/.well-known/did.json` (Depends on supporting did:web). 66 92 67 - ## 6. Record Schema Validation 93 + ## Admin Management (`com.atproto.admin`) 94 + - [ ] Implement `com.atproto.admin.deleteAccount`. 95 + - [ ] Implement `com.atproto.admin.disableAccountInvites`. 96 + - [ ] Implement `com.atproto.admin.disableInviteCodes`. 97 + - [ ] Implement `com.atproto.admin.enableAccountInvites`. 98 + - [ ] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`. 99 + - [ ] Implement `com.atproto.admin.getInviteCodes`. 100 + - [ ] Implement `com.atproto.admin.getSubjectStatus`. 101 + - [ ] Implement `com.atproto.admin.sendEmail`. 102 + - [ ] Implement `com.atproto.admin.updateAccountEmail`. 103 + - [ ] Implement `com.atproto.admin.updateAccountHandle`. 104 + - [ ] Implement `com.atproto.admin.updateAccountPassword`. 105 + - [ ] Implement `com.atproto.admin.updateSubjectStatus`. 106 + 107 + ## Moderation (`com.atproto.moderation`) 108 + - [ ] Implement `com.atproto.moderation.createReport`. 109 + 110 + ## Record Schema Validation 68 111 - [ ] `app.bsky.feed.post` 69 112 - [ ] `app.bsky.feed.like` 70 113 - [ ] `app.bsky.feed.repost` ··· 73 116 - [ ] `app.bsky.actor.profile` 74 117 - [ ] Other app(view) validation too!!! 75 118 76 - ## 7. General Requirements 119 + ## Infrastructure & Core Components 120 + - [ ] Sequencer (Event Log) 121 + - [ ] Implement a `Sequencer` (backed by `repo_seq` table? Like in ref impl). 122 + - [ ] Implement event formatting (`commit`, `handle`, `identity`, `account`). 123 + - [ ] Implement database polling / event emission mechanism. 124 + - [ ] Implement cursor-based event replay (`requestSeqRange`). 125 + - [ ] Repo Storage & Consistency (in postgres) 126 + - [ ] Implement `RepoStorage` for postgres (replaces per-user SQLite). 127 + - [ ] Read/Write IPLD blocks to `blocks` table (global deduplication). 128 + - [ ] Manage Repo Root in `repos` table. 129 + - [ ] Implement Atomic Repo Transactions. 130 + - [ ] Ensure `blocks` write, `repo_root` update, `records` index update, and `sequencer` event are committed in a single transaction. 131 + - [ ] Implement concurrency control (row-level locking on `repos` table) to prevent concurrent writes to the same repo. 132 + - [ ] DID Cache 133 + - [ ] Implement caching layer for DID resolution (Redis or in-memory). 134 + - [ ] Handle cache invalidation/expiry. 135 + - [ ] Background Jobs 136 + - [ ] Implement background queue for async tasks (crawler notifications, discord/telegram 2FA sending instead of email). 137 + - [ ] Implement `Crawlers` service (debounce notifications to relays). 138 + - [ ] Mailer equivalent 139 + - [ ] Implement code/notification sending service as a replacement for the mailer because there's no way I'm starting with email. :D 140 + - [ ] Image Processing 141 + - [ ] Implement image resize/formatting pipeline (for blob uploads). 77 142 - [ ] IPLD & MST 78 - - [ ] Implement Merkle Search Tree (MST) logic for repo signing. 79 - - [ ] Implement CAR (Content Addressable Archives) encoding/decoding. 143 + - [ ] Implement Merkle Search Tree logic for repo signing. 144 + - [ ] Implement CAR (Content Addressable Archive) encoding/decoding. 80 145 - [ ] Validation 81 146 - [ ] DID PLC Operations (Sign rotation keys). 147 +
+1
justfile
··· 14 14 test-others: 15 15 cargo test --lib 16 16 cargo test --test actor 17 + cargo test --test auth 17 18 cargo test --test feed 18 19 cargo test --test graph 19 20 cargo test --test identity
+11
ref_pds_downloader.sh
··· 1 + git clone --depth 1 --filter=blob:none --sparse https://github.com/bluesky-social/atproto.git reference-pds 2 + 3 + cd reference-pds 4 + 5 + git sparse-checkout set packages/pds 6 + 7 + git checkout main 8 + 9 + mv packages/pds/* . 10 + mv packages/pds/.[!.]* . 2>/dev/null 11 + rm -rf .git
+36 -4
src/api/proxy.rs
··· 1 1 use axum::{ 2 - extract::{Path, Query}, 2 + extract::{Path, Query, State}, 3 3 http::{HeaderMap, Method, StatusCode}, 4 4 response::{IntoResponse, Response}, 5 5 body::Bytes, ··· 7 7 use reqwest::Client; 8 8 use tracing::{info, error}; 9 9 use std::collections::HashMap; 10 + use crate::state::AppState; 11 + use sqlx::Row; 10 12 11 13 pub async fn proxy_handler( 14 + State(state): State<AppState>, 12 15 Path(method): Path<String>, 13 16 method_verb: Method, 14 17 headers: HeaderMap, ··· 20 23 .and_then(|h| h.to_str().ok()) 21 24 .map(|s| s.to_string()); 22 25 23 - let appview_url = match proxy_header { 24 - Some(url) => url, 26 + let appview_url = match &proxy_header { 27 + Some(url) => url.clone(), 25 28 None => match std::env::var("APPVIEW_URL") { 26 29 Ok(url) => url, 27 30 Err(_) => return (StatusCode::BAD_GATEWAY, "No upstream AppView configured").into_response(), ··· 38 41 .request(method_verb, &target_url) 39 42 .query(&params); 40 43 44 + let mut auth_header_val = headers.get("Authorization").map(|h| h.clone()); 45 + 46 + if let Some(aud) = &proxy_header { 47 + if let Some(auth_val) = &auth_header_val { 48 + if let Ok(token) = auth_val.to_str() { 49 + let token = token.replace("Bearer ", ""); 50 + if let Ok(did) = crate::auth::get_did_from_token(&token) { 51 + let key_row = sqlx::query("SELECT k.key_bytes FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1") 52 + .bind(&did) 53 + .fetch_optional(&state.db) 54 + .await; 55 + 56 + if let Ok(Some(row)) = key_row { 57 + let key_bytes: Vec<u8> = row.get("key_bytes"); 58 + if let Ok(new_token) = crate::auth::create_service_token(&did, aud, &method, &key_bytes) { 59 + if let Ok(val) = axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) { 60 + auth_header_val = Some(val); 61 + } 62 + } 63 + } 64 + } 65 + } 66 + } 67 + } 68 + 69 + if let Some(val) = auth_header_val { 70 + request_builder = request_builder.header("Authorization", val); 71 + } 72 + 41 73 for (key, value) in headers.iter() { 42 - if key != "host" && key != "content-length" { 74 + if key != "host" && key != "content-length" && key != "authorization" { 43 75 request_builder = request_builder.header(key, value); 44 76 } 45 77 }
+9 -7
src/api/repo.rs
··· 46 46 } 47 47 let token = auth_header.unwrap().to_str().unwrap_or("").replace("Bearer ", ""); 48 48 49 - if let Err(_) = crate::auth::verify_token(&token) { 50 - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"}))).into_response(); 51 - } 52 - 53 - let session = sqlx::query("SELECT did FROM sessions WHERE access_jwt = $1") 49 + let session = sqlx::query( 50 + "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1" 51 + ) 54 52 .bind(&token) 55 53 .fetch_optional(&state.db) 56 54 .await 57 55 .unwrap_or(None); 58 56 59 - let did = match session { 60 - Some(row) => row.get::<String, _>("did"), 57 + let (did, key_bytes) = match session { 58 + Some(row) => (row.get::<String, _>("did"), row.get::<Vec<u8>, _>("key_bytes")), 61 59 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed"}))).into_response(), 62 60 }; 61 + 62 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 63 + return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"}))).into_response(); 64 + } 63 65 64 66 if input.repo != did { 65 67 return (StatusCode::FORBIDDEN, Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"}))).into_response();
+41 -18
src/api/server.rs
··· 13 13 use jacquard_repo::{mst::Mst, commit::Commit, storage::BlockStore}; 14 14 use jacquard::types::{string::Tid, did::Did, integer::LimitedU32}; 15 15 use std::sync::Arc; 16 + use k256::SecretKey; 17 + use rand::rngs::OsRng; 16 18 17 19 pub async fn describe_server() -> impl IntoResponse { 18 20 let domains_str = std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| "example.com".to_string()); ··· 139 141 } 140 142 }; 141 143 144 + let secret_key = SecretKey::random(&mut OsRng); 145 + let secret_key_bytes = secret_key.to_bytes(); 146 + 147 + let key_insert = sqlx::query("INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)") 148 + .bind(user_id) 149 + .bind(&secret_key_bytes[..]) 150 + .execute(&mut *tx) 151 + .await; 152 + 153 + if let Err(e) = key_insert { 154 + error!("Error inserting user key: {:?}", e); 155 + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response(); 156 + } 157 + 142 158 let store = Arc::new(state.block_store.clone()); 143 159 let mst = Mst::new(store.clone()); 144 160 let mst_root = match mst.root().await { ··· 203 219 } 204 220 } 205 221 206 - let access_jwt = crate::auth::create_access_token(&did).map_err(|e| { 222 + let access_jwt = crate::auth::create_access_token(&did, &secret_key_bytes[..]).map_err(|e| { 207 223 error!("Error creating access token: {:?}", e); 208 224 (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response() 209 225 }); ··· 212 228 Err(r) => return r, 213 229 }; 214 230 215 - let refresh_jwt = crate::auth::create_refresh_token(&did).map_err(|e| { 231 + let refresh_jwt = crate::auth::create_refresh_token(&did, &secret_key_bytes[..]).map_err(|e| { 216 232 error!("Error creating refresh token: {:?}", e); 217 233 (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response() 218 234 }); ··· 267 283 ) -> Response { 268 284 info!("create_session: identifier='{}'", input.identifier); 269 285 270 - let user_row = sqlx::query("SELECT did, handle, password_hash FROM users WHERE handle = $1 OR email = $1") 286 + let user_row = sqlx::query("SELECT u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1") 271 287 .bind(&input.identifier) 272 288 .fetch_optional(&state.db) 273 289 .await; ··· 279 295 if verify(&input.password, &stored_hash).unwrap_or(false) { 280 296 let did: String = row.get("did"); 281 297 let handle: String = row.get("handle"); 298 + let key_bytes: Vec<u8> = row.get("key_bytes"); 282 299 283 - let access_jwt = match crate::auth::create_access_token(&did) { 300 + let access_jwt = match crate::auth::create_access_token(&did, &key_bytes) { 284 301 Ok(t) => t, 285 302 Err(e) => { 286 303 error!("Failed to create access token: {:?}", e); ··· 288 305 } 289 306 }; 290 307 291 - let refresh_jwt = match crate::auth::create_refresh_token(&did) { 308 + let refresh_jwt = match crate::auth::create_refresh_token(&did, &key_bytes) { 292 309 Ok(t) => t, 293 310 Err(e) => { 294 311 error!("Failed to create refresh token: {:?}", e); ··· 344 361 345 362 let token = auth_header.unwrap().to_str().unwrap_or("").replace("Bearer ", ""); 346 363 347 - if let Err(_) = crate::auth::verify_token(&token) { 348 - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"}))).into_response(); 349 - } 350 - 351 364 let result = sqlx::query( 352 365 r#" 353 - SELECT u.handle, u.did, u.email 366 + SELECT u.handle, u.did, u.email, k.key_bytes 354 367 FROM sessions s 355 368 JOIN users u ON s.did = u.did 369 + JOIN user_keys k ON u.id = k.user_id 356 370 WHERE s.access_jwt = $1 357 371 "# 358 372 ) 359 - .bind(token) 373 + .bind(&token) 360 374 .fetch_optional(&state.db) 361 375 .await; 362 376 ··· 365 379 let handle: String = row.get("handle"); 366 380 let did: String = row.get("did"); 367 381 let email: String = row.get("email"); 382 + let key_bytes: Vec<u8> = row.get("key_bytes"); 383 + 384 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 385 + return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"}))).into_response(); 386 + } 368 387 369 388 return (StatusCode::OK, Json(json!({ 370 389 "handle": handle, ··· 424 443 425 444 let refresh_token = auth_header.unwrap().to_str().unwrap_or("").replace("Bearer ", ""); 426 445 427 - if let Err(_) = crate::auth::verify_token(&refresh_token) { 428 - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token"}))).into_response(); 429 - } 430 - 431 - let session = sqlx::query("SELECT did FROM sessions WHERE refresh_jwt = $1") 446 + let session = sqlx::query( 447 + "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.refresh_jwt = $1" 448 + ) 432 449 .bind(&refresh_token) 433 450 .fetch_optional(&state.db) 434 451 .await; ··· 436 453 match session { 437 454 Ok(Some(session_row)) => { 438 455 let did: String = session_row.get("did"); 439 - let new_access_jwt = match crate::auth::create_access_token(&did) { 456 + let key_bytes: Vec<u8> = session_row.get("key_bytes"); 457 + 458 + if let Err(_) = crate::auth::verify_token(&refresh_token, &key_bytes) { 459 + return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token signature"}))).into_response(); 460 + } 461 + 462 + let new_access_jwt = match crate::auth::create_access_token(&did, &key_bytes) { 440 463 Ok(t) => t, 441 464 Err(e) => { 442 465 error!("Failed to create access token: {:?}", e); 443 466 return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response(); 444 467 } 445 468 }; 446 - let new_refresh_jwt = match crate::auth::create_refresh_token(&did) { 469 + let new_refresh_jwt = match crate::auth::create_refresh_token(&did, &key_bytes) { 447 470 Ok(t) => t, 448 471 Err(e) => { 449 472 error!("Failed to create refresh token: {:?}", e);
+119 -21
src/auth.rs
··· 1 - use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey, TokenData}; 2 1 use serde::{Deserialize, Serialize}; 3 2 use chrono::{Utc, Duration}; 4 - use std::env; 3 + use k256::ecdsa::{SigningKey, VerifyingKey, signature::Signer, signature::Verifier, Signature}; 4 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5 + use anyhow::{Context, Result, anyhow}; 5 6 6 7 #[derive(Debug, Serialize, Deserialize)] 7 8 pub struct Claims { 8 - // DID type shit 9 + pub iss: String, 9 10 pub sub: String, 11 + pub aud: String, 10 12 pub exp: usize, 11 13 pub iat: usize, 12 - pub scope: String, 14 + #[serde(skip_serializing_if = "Option::is_none")] 15 + pub scope: Option<String>, 16 + #[serde(skip_serializing_if = "Option::is_none")] 17 + pub lxm: Option<String>, 13 18 pub jti: String, 14 19 } 15 20 16 - pub fn create_access_token(did: &str) -> Result<String, jsonwebtoken::errors::Error> { 17 - let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); 21 + #[derive(Debug, Serialize, Deserialize)] 22 + struct Header { 23 + alg: String, 24 + typ: String, 25 + } 26 + 27 + #[derive(Debug, Serialize, Deserialize)] 28 + struct UnsafeClaims { 29 + iss: String, 30 + sub: Option<String>, 31 + } 32 + 33 + // fancy boy TokenData equivalent for compatibility/structure 34 + pub struct TokenData<T> { 35 + pub claims: T, 36 + } 37 + 38 + pub fn get_did_from_token(token: &str) -> Result<String, String> { 39 + let parts: Vec<&str> = token.split('.').collect(); 40 + if parts.len() != 3 { 41 + return Err("Invalid token format".to_string()); 42 + } 43 + 44 + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]) 45 + .map_err(|e| format!("Base64 decode failed: {}", e))?; 46 + 47 + let claims: UnsafeClaims = serde_json::from_slice(&payload_bytes) 48 + .map_err(|e| format!("JSON decode failed: {}", e))?; 49 + 50 + Ok(claims.sub.unwrap_or(claims.iss)) 51 + } 52 + 53 + pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String, anyhow::Error> { 54 + create_signed_token(did, "access", key_bytes, Duration::minutes(15)) 55 + } 56 + 57 + pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String, anyhow::Error> { 58 + create_signed_token(did, "refresh", key_bytes, Duration::days(7)) 59 + } 60 + 61 + pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result<String, anyhow::Error> { 62 + let signing_key = SigningKey::from_slice(key_bytes)?; 63 + 18 64 let expiration = Utc::now() 19 - .checked_add_signed(Duration::minutes(15)) 65 + .checked_add_signed(Duration::seconds(60)) 20 66 .expect("valid timestamp") 21 67 .timestamp(); 22 68 23 69 let claims = Claims { 70 + iss: did.to_owned(), 24 71 sub: did.to_owned(), 72 + aud: aud.to_owned(), 25 73 exp: expiration as usize, 26 74 iat: Utc::now().timestamp() as usize, 27 - scope: "access".to_string(), 75 + scope: None, 76 + lxm: Some(lxm.to_string()), 28 77 jti: uuid::Uuid::new_v4().to_string(), 29 78 }; 30 79 31 - encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref())) 80 + sign_claims(claims, &signing_key) 32 81 } 33 82 34 - pub fn create_refresh_token(did: &str) -> Result<String, jsonwebtoken::errors::Error> { 35 - let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); 83 + fn create_signed_token(did: &str, scope: &str, key_bytes: &[u8], duration: Duration) -> Result<String, anyhow::Error> { 84 + let signing_key = SigningKey::from_slice(key_bytes)?; 85 + 36 86 let expiration = Utc::now() 37 - .checked_add_signed(Duration::days(7)) 87 + .checked_add_signed(duration) 38 88 .expect("valid timestamp") 39 89 .timestamp(); 40 90 41 91 let claims = Claims { 92 + iss: did.to_owned(), 42 93 sub: did.to_owned(), 94 + aud: format!("did:web:{}", std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())), 43 95 exp: expiration as usize, 44 96 iat: Utc::now().timestamp() as usize, 45 - scope: "refresh".to_string(), 97 + scope: Some(scope.to_string()), 98 + lxm: None, 46 99 jti: uuid::Uuid::new_v4().to_string(), 47 100 }; 48 101 49 - encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref())) 102 + sign_claims(claims, &signing_key) 50 103 } 51 104 52 - pub fn verify_token(token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> { 53 - let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); 54 - decode::<Claims>( 55 - token, 56 - &DecodingKey::from_secret(secret.as_ref()), 57 - &Validation::default(), 58 - ) 105 + fn sign_claims(claims: Claims, key: &SigningKey) -> Result<String, anyhow::Error> { 106 + let header = Header { 107 + alg: "ES256K".to_string(), 108 + typ: "JWT".to_string(), 109 + }; 110 + 111 + let header_json = serde_json::to_string(&header)?; 112 + let claims_json = serde_json::to_string(&claims)?; 113 + 114 + let header_b64 = URL_SAFE_NO_PAD.encode(header_json); 115 + let claims_b64 = URL_SAFE_NO_PAD.encode(claims_json); 116 + 117 + let message = format!("{}.{}", header_b64, claims_b64); 118 + let signature: Signature = key.sign(message.as_bytes()); 119 + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 120 + 121 + Ok(format!("{}.{}", message, signature_b64)) 122 + } 123 + 124 + pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>, anyhow::Error> { 125 + let parts: Vec<&str> = token.split('.').collect(); 126 + if parts.len() != 3 { 127 + return Err(anyhow!("Invalid token format")); 128 + } 129 + 130 + let header_b64 = parts[0]; 131 + let claims_b64 = parts[1]; 132 + let signature_b64 = parts[2]; 133 + 134 + let signature_bytes = URL_SAFE_NO_PAD.decode(signature_b64) 135 + .context("Base64 decode of signature failed")?; 136 + let signature = Signature::from_slice(&signature_bytes) 137 + .map_err(|e| anyhow!("Invalid signature format: {}", e))?; 138 + 139 + let signing_key = SigningKey::from_slice(key_bytes)?; 140 + let verifying_key = VerifyingKey::from(&signing_key); 141 + 142 + let message = format!("{}.{}", header_b64, claims_b64); 143 + verifying_key.verify(message.as_bytes(), &signature) 144 + .map_err(|e| anyhow!("Signature verification failed: {}", e))?; 145 + 146 + let claims_bytes = URL_SAFE_NO_PAD.decode(claims_b64) 147 + .context("Base64 decode of claims failed")?; 148 + let claims: Claims = serde_json::from_slice(&claims_bytes) 149 + .context("JSON decode of claims failed")?; 150 + 151 + let now = Utc::now().timestamp() as usize; 152 + if claims.exp < now { 153 + return Err(anyhow!("Token expired")); 154 + } 155 + 156 + Ok(TokenData { claims }) 59 157 }
+122
tests/auth.rs
··· 1 + use bspds::auth; 2 + use k256::SecretKey; 3 + use rand::rngs::OsRng; 4 + use chrono::{Utc, Duration}; 5 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 6 + use serde_json::json; 7 + use k256::ecdsa::{SigningKey, signature::Signer}; 8 + 9 + #[test] 10 + fn test_jwt_flow() { 11 + let secret_key = SecretKey::random(&mut OsRng); 12 + let key_bytes = secret_key.to_bytes(); 13 + let did = "did:plc:test"; 14 + 15 + let token = auth::create_access_token(did, &key_bytes).expect("create token"); 16 + let data = auth::verify_token(&token, &key_bytes).expect("verify token"); 17 + assert_eq!(data.claims.sub, did); 18 + assert_eq!(data.claims.iss, did); 19 + assert_eq!(data.claims.scope, Some("access".to_string())); 20 + 21 + let r_token = auth::create_refresh_token(did, &key_bytes).expect("create refresh token"); 22 + let r_data = auth::verify_token(&r_token, &key_bytes).expect("verify refresh token"); 23 + assert_eq!(r_data.claims.scope, Some("refresh".to_string())); 24 + 25 + let aud = "did:web:service"; 26 + let lxm = "com.example.test"; 27 + let s_token = auth::create_service_token(did, aud, lxm, &key_bytes).expect("create service token"); 28 + let s_data = auth::verify_token(&s_token, &key_bytes).expect("verify service token"); 29 + assert_eq!(s_data.claims.aud, aud); 30 + assert_eq!(s_data.claims.lxm, Some(lxm.to_string())); 31 + } 32 + 33 + #[test] 34 + fn test_verify_fails_with_wrong_key() { 35 + let secret_key1 = SecretKey::random(&mut OsRng); 36 + let key_bytes1 = secret_key1.to_bytes(); 37 + 38 + let secret_key2 = SecretKey::random(&mut OsRng); 39 + let key_bytes2 = secret_key2.to_bytes(); 40 + 41 + let did = "did:plc:test"; 42 + let token = auth::create_access_token(did, &key_bytes1).expect("create token"); 43 + 44 + let result = auth::verify_token(&token, &key_bytes2); 45 + assert!(result.is_err()); 46 + } 47 + 48 + #[test] 49 + fn test_token_expiration() { 50 + let secret_key = SecretKey::random(&mut OsRng); 51 + let key_bytes = secret_key.to_bytes(); 52 + let signing_key = SigningKey::from_slice(&key_bytes).expect("key"); 53 + 54 + let header = json!({ 55 + "alg": "ES256K", 56 + "typ": "JWT" 57 + }); 58 + let claims = json!({ 59 + "iss": "did:plc:test", 60 + "sub": "did:plc:test", 61 + "aud": "did:web:test", 62 + "exp": (Utc::now() - Duration::seconds(10)).timestamp(), 63 + "iat": (Utc::now() - Duration::minutes(1)).timestamp(), 64 + "jti": "unique", 65 + }); 66 + 67 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 68 + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); 69 + let message = format!("{}.{}", header_b64, claims_b64); 70 + let signature: k256::ecdsa::Signature = signing_key.sign(message.as_bytes()); 71 + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 72 + let token = format!("{}.{}", message, signature_b64); 73 + 74 + let result = auth::verify_token(&token, &key_bytes); 75 + match result { 76 + Ok(_) => panic!("Token should be expired"), 77 + Err(e) => assert_eq!(e.to_string(), "Token expired"), 78 + } 79 + } 80 + 81 + #[test] 82 + fn test_invalid_token_format() { 83 + let secret_key = SecretKey::random(&mut OsRng); 84 + let key_bytes = secret_key.to_bytes(); 85 + 86 + assert!(auth::verify_token("invalid.token", &key_bytes).is_err()); 87 + assert!(auth::verify_token("too.many.parts.here", &key_bytes).is_err()); 88 + assert!(auth::verify_token("bad_base64.payload.sig", &key_bytes).is_err()); 89 + } 90 + 91 + #[test] 92 + fn test_tampered_token() { 93 + let secret_key = SecretKey::random(&mut OsRng); 94 + let key_bytes = secret_key.to_bytes(); 95 + let did = "did:plc:test"; 96 + 97 + let token = auth::create_access_token(did, &key_bytes).expect("create token"); 98 + let parts: Vec<&str> = token.split('.').collect(); 99 + 100 + let claims_json = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); 101 + let mut claims: serde_json::Value = serde_json::from_str(&claims_json).unwrap(); 102 + claims["sub"] = json!("did:plc:hacker"); 103 + let tampered_claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); 104 + 105 + let tampered_token = format!("{}.{}.{}", parts[0], tampered_claims_b64, parts[2]); 106 + 107 + let result = auth::verify_token(&tampered_token, &key_bytes); 108 + assert!(result.is_err()); 109 + } 110 + 111 + #[test] 112 + fn test_get_did_from_token() { 113 + let secret_key = SecretKey::random(&mut OsRng); 114 + let key_bytes = secret_key.to_bytes(); 115 + let did = "did:plc:test"; 116 + 117 + let token = auth::create_access_token(did, &key_bytes).expect("create token"); 118 + let extracted_did = auth::get_did_from_token(&token).expect("get did"); 119 + assert_eq!(extracted_did, did); 120 + 121 + assert!(auth::get_did_from_token("bad.token").is_err()); 122 + }
+2
tests/common/mod.rs
··· 24 24 #[allow(dead_code)] 25 25 pub const TARGET_DID: &str = "did:plc:target"; 26 26 27 + #[allow(dead_code)] 27 28 pub fn client() -> Client { 28 29 Client::new() 29 30 } ··· 142 143 (uri, cid, rkey) 143 144 } 144 145 146 + #[allow(dead_code)] 145 147 pub async fn create_account_and_login(client: &Client) -> (String, String) { 146 148 let handle = format!("user_{}", uuid::Uuid::new_v4()); 147 149 let payload = json!({
+37
tests/proxy.rs
··· 9 9 use tokio::net::TcpListener; 10 10 use reqwest::Client; 11 11 use std::sync::Arc; 12 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 12 13 13 14 async fn spawn_mock_upstream() -> (String, tokio::sync::mpsc::Receiver<(String, String, Option<String>)>) { 14 15 let (tx, rx) = tokio::sync::mpsc::channel(10); ··· 94 95 95 96 assert_eq!(res.status(), StatusCode::BAD_GATEWAY); 96 97 } 98 + 99 + #[tokio::test] 100 + async fn test_proxy_auth_signing() { 101 + let app_url = common::base_url().await; 102 + let (upstream_url, mut rx) = spawn_mock_upstream().await; 103 + let client = Client::new(); 104 + 105 + let (access_jwt, did) = common::create_account_and_login(&client).await; 106 + 107 + let res = client.get(format!("{}/xrpc/com.example.signed", app_url)) 108 + .header("atproto-proxy", &upstream_url) 109 + .header("Authorization", format!("Bearer {}", access_jwt)) 110 + .send() 111 + .await 112 + .unwrap(); 113 + 114 + assert_eq!(res.status(), StatusCode::OK); 115 + 116 + let (method, uri, auth) = rx.recv().await.expect("Upstream receive"); 117 + assert_eq!(method, "GET"); 118 + assert_eq!(uri, "/xrpc/com.example.signed"); 119 + 120 + let received_token = auth.expect("No auth header").replace("Bearer ", ""); 121 + assert_ne!(received_token, access_jwt, "Token should be replaced"); 122 + 123 + let parts: Vec<&str> = received_token.split('.').collect(); 124 + assert_eq!(parts.len(), 3); 125 + 126 + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).expect("payload b64"); 127 + let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).expect("payload json"); 128 + 129 + assert_eq!(claims["iss"], did); 130 + assert_eq!(claims["sub"], did); 131 + assert_eq!(claims["aud"], upstream_url); 132 + assert_eq!(claims["lxm"], "com.example.signed"); 133 + }