Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

dev and prod with all the oauth joy

Changed files
+254 -24
who-am-i
+113
Cargo.lock
··· 1162 1162 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 1163 1163 dependencies = [ 1164 1164 "const-oid", 1165 + "pem-rfc7468", 1165 1166 "zeroize", 1166 1167 ] 1167 1168 ··· 1344 1345 "elliptic-curve", 1345 1346 "rfc6979", 1346 1347 "signature", 1348 + "spki", 1347 1349 ] 1348 1350 1349 1351 [[package]] ··· 1364 1366 "ff", 1365 1367 "generic-array", 1366 1368 "group", 1369 + "pem-rfc7468", 1370 + "pkcs8", 1367 1371 "rand_core 0.6.4", 1368 1372 "sec1", 1369 1373 "subtle", ··· 2442 2446 "jose-b64", 2443 2447 "jose-jwa", 2444 2448 "p256", 2449 + "p384", 2450 + "rsa", 2445 2451 "serde", 2446 2452 "zeroize", 2447 2453 ] ··· 2485 2491 version = "1.5.0" 2486 2492 source = "registry+https://github.com/rust-lang/crates.io-index" 2487 2493 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2494 + dependencies = [ 2495 + "spin", 2496 + ] 2488 2497 2489 2498 [[package]] 2490 2499 name = "lazycell" ··· 2982 2991 ] 2983 2992 2984 2993 [[package]] 2994 + name = "num-bigint-dig" 2995 + version = "0.8.4" 2996 + source = "registry+https://github.com/rust-lang/crates.io-index" 2997 + checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" 2998 + dependencies = [ 2999 + "byteorder", 3000 + "lazy_static", 3001 + "libm", 3002 + "num-integer", 3003 + "num-iter", 3004 + "num-traits", 3005 + "rand 0.8.5", 3006 + "smallvec", 3007 + "zeroize", 3008 + ] 3009 + 3010 + [[package]] 2985 3011 name = "num-conv" 2986 3012 version = "0.1.0" 2987 3013 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3007 3033 ] 3008 3034 3009 3035 [[package]] 3036 + name = "num-iter" 3037 + version = "0.1.45" 3038 + source = "registry+https://github.com/rust-lang/crates.io-index" 3039 + checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" 3040 + dependencies = [ 3041 + "autocfg", 3042 + "num-integer", 3043 + "num-traits", 3044 + ] 3045 + 3046 + [[package]] 3010 3047 name = "num-modular" 3011 3048 version = "0.6.1" 3012 3049 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3028 3065 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 3029 3066 dependencies = [ 3030 3067 "autocfg", 3068 + "libm", 3031 3069 ] 3032 3070 3033 3071 [[package]] ··· 3142 3180 ] 3143 3181 3144 3182 [[package]] 3183 + name = "p384" 3184 + version = "0.13.1" 3185 + source = "registry+https://github.com/rust-lang/crates.io-index" 3186 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 3187 + dependencies = [ 3188 + "elliptic-curve", 3189 + "primeorder", 3190 + ] 3191 + 3192 + [[package]] 3145 3193 name = "parking" 3146 3194 version = "2.2.1" 3147 3195 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3202 3250 dependencies = [ 3203 3251 "base64 0.22.1", 3204 3252 "serde", 3253 + ] 3254 + 3255 + [[package]] 3256 + name = "pem-rfc7468" 3257 + version = "0.7.0" 3258 + source = "registry+https://github.com/rust-lang/crates.io-index" 3259 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 3260 + dependencies = [ 3261 + "base64ct", 3205 3262 ] 3206 3263 3207 3264 [[package]] ··· 3267 3324 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 3268 3325 3269 3326 [[package]] 3327 + name = "pkcs1" 3328 + version = "0.7.5" 3329 + source = "registry+https://github.com/rust-lang/crates.io-index" 3330 + checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" 3331 + dependencies = [ 3332 + "der", 3333 + "pkcs8", 3334 + "spki", 3335 + ] 3336 + 3337 + [[package]] 3338 + name = "pkcs8" 3339 + version = "0.10.2" 3340 + source = "registry+https://github.com/rust-lang/crates.io-index" 3341 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 3342 + dependencies = [ 3343 + "der", 3344 + "spki", 3345 + ] 3346 + 3347 + [[package]] 3270 3348 name = "pkg-config" 3271 3349 version = "0.3.32" 3272 3350 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3666 3744 ] 3667 3745 3668 3746 [[package]] 3747 + name = "rsa" 3748 + version = "0.9.8" 3749 + source = "registry+https://github.com/rust-lang/crates.io-index" 3750 + checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" 3751 + dependencies = [ 3752 + "const-oid", 3753 + "digest", 3754 + "num-bigint-dig", 3755 + "num-integer", 3756 + "num-traits", 3757 + "pkcs1", 3758 + "pkcs8", 3759 + "rand_core 0.6.4", 3760 + "signature", 3761 + "spki", 3762 + "subtle", 3763 + "zeroize", 3764 + ] 3765 + 3766 + [[package]] 3669 3767 name = "rustc-demangle" 3670 3768 version = "0.1.24" 3671 3769 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3873 3971 "base16ct", 3874 3972 "der", 3875 3973 "generic-array", 3974 + "pkcs8", 3876 3975 "subtle", 3877 3976 "zeroize", 3878 3977 ] ··· 4266 4365 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 4267 4366 dependencies = [ 4268 4367 "lock_api", 4368 + ] 4369 + 4370 + [[package]] 4371 + name = "spki" 4372 + version = "0.7.3" 4373 + source = "registry+https://github.com/rust-lang/crates.io-index" 4374 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 4375 + dependencies = [ 4376 + "base64ct", 4377 + "der", 4269 4378 ] 4270 4379 4271 4380 [[package]] ··· 5143 5252 "clap", 5144 5253 "ctrlc", 5145 5254 "dashmap", 5255 + "elliptic-curve", 5146 5256 "handlebars", 5147 5257 "hickory-resolver", 5258 + "jose-jwk", 5148 5259 "jsonwebtoken", 5149 5260 "metrics", 5150 5261 "metrics-exporter-prometheus 0.17.2", 5262 + "p256", 5263 + "pkcs8", 5151 5264 "rand 0.9.1", 5152 5265 "reqwest", 5153 5266 "serde",
+4
who-am-i/Cargo.toml
··· 14 14 clap = { version = "4.5.40", features = ["derive", "env"] } 15 15 ctrlc = "3.4.7" 16 16 dashmap = "6.1.0" 17 + elliptic-curve = "0.13.8" 17 18 handlebars = { version = "6.3.2", features = ["dir_source"] } 18 19 hickory-resolver = "0.25.2" 20 + jose-jwk = "0.1.2" 19 21 jsonwebtoken = "9.3.1" 20 22 metrics = "0.24.2" 23 + p256 = "0.13.2" 24 + pkcs8 = "0.10.2" 21 25 rand = "0.9.1" 22 26 reqwest = { version = "0.12.22", features = ["native-tls-vendored"] } 23 27 serde = { version = "1.0.219", features = ["derive"] }
+38 -1
who-am-i/src/main.rs
··· 15 15 /// eg: `cat /dev/urandom | head -c 64 | base64` 16 16 #[arg(long, env)] 17 17 app_secret: String, 18 + /// path to at-oauth private key (PEM pk8 format) 19 + /// 20 + /// generate with: 21 + /// 22 + /// openssl ecparam -genkey -noout -name prime256v1 \ 23 + /// | openssl pkcs8 -topk8 -nocrypt -out <PATH-TO-PRIV-KEY>.pem 24 + #[arg(long, env)] 25 + oauth_private_key: Option<PathBuf>, 18 26 /// path to jwt private key (PEM pk8 format) 19 27 /// 20 28 /// generate with: ··· 34 42 /// wrap the jwk in an array, then in an object under "keys": 35 43 /// 36 44 /// { "keys": [<JWK obj>] } 45 + /// 46 + /// TODO: remove this, serve automatically 37 47 #[arg(long)] 38 48 jwks: PathBuf, 49 + /// this server's client-reachable base url, for oauth redirect + jwt check 50 + /// 51 + /// required unless running in localhost mode with --dev 52 + #[arg(long, env)] 53 + base_url: Option<String>, 54 + /// host:port to bind to on startup 55 + #[arg(long, env, default_value = "127.0.0.1:9997")] 56 + bind: String, 39 57 /// Enable dev mode 40 58 /// 41 - /// enables automatic template reloading 59 + /// enables automatic template reloading, uses localhost oauth config, etc 42 60 #[arg(long, action)] 43 61 dev: bool, 44 62 /// Hosts who are allowed to one-click auth ··· 57 75 58 76 let args = Args::parse(); 59 77 78 + // let bind = args.bind.to_socket_addrs().expect("--bind must be ToSocketAddrs"); 79 + 80 + let base = args.base_url.unwrap_or_else(|| { 81 + if args.dev { 82 + format!("http://{}", args.bind) 83 + } else { 84 + panic!("not in --dev mode so --base-url is required") 85 + } 86 + }); 87 + 88 + if !args.dev && args.oauth_private_key.is_none() { 89 + panic!("--at-oauth-key is required except in --dev"); 90 + } else if args.dev && args.oauth_private_key.is_some() { 91 + eprintln!("warn: --at-oauth-key is ignored in dev (localhost config)"); 92 + } 93 + 60 94 if args.allowed_hosts.is_empty() { 61 95 panic!("at least one --allowed-host host must be set"); 62 96 } ··· 75 109 serve( 76 110 shutdown, 77 111 args.app_secret, 112 + args.oauth_private_key, 78 113 tokens, 114 + base, 115 + args.bind, 79 116 args.allowed_hosts, 80 117 args.dev, 81 118 )
+77 -21
who-am-i/src/oauth.rs
··· 1 + use jose_jwk::Class; 2 + use jose_jwk::Jwk; 3 + use jose_jwk::Key; 4 + use jose_jwk::Parameters; 5 + use std::fs; 6 + use std::path::PathBuf; 7 + // use p256::SecretKey; 1 8 use atrium_api::{agent::SessionManager, types::string::Did}; 2 9 use atrium_common::resolver::Resolver; 3 10 use atrium_identity::{ ··· 5 12 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig, DnsTxtResolver}, 6 13 }; 7 14 use atrium_oauth::{ 8 - AtprotoLocalhostClientMetadata, AuthorizeOptions, CallbackParams, DefaultHttpClient, 9 - KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 15 + AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, AuthorizeOptions, 16 + CallbackParams, DefaultHttpClient, GrantType, KnownScope, OAuthClient, OAuthClientConfig, 17 + OAuthClientMetadata, OAuthResolverConfig, Scope, 10 18 store::{session::MemorySessionStore, state::MemoryStateStore}, 11 19 }; 20 + use elliptic_curve::SecretKey; 12 21 use hickory_resolver::{ResolveError, TokioResolver}; 22 + use jose_jwk::JwkSet; 23 + use pkcs8::DecodePrivateKey; 13 24 use serde::Deserialize; 14 25 use std::sync::Arc; 15 26 use thiserror::Error; ··· 83 94 } 84 95 85 96 impl OAuth { 86 - pub fn new() -> Result<Self, AuthSetupError> { 97 + pub fn new(oauth_private_key: Option<PathBuf>, base: String) -> Result<Self, AuthSetupError> { 87 98 let http_client = Arc::new(DefaultHttpClient::default()); 88 99 let did_resolver = || { 89 100 CommonDidResolver::new(CommonDidResolverConfig { ··· 93 104 }; 94 105 let dns_txt_resolver = 95 106 HickoryDnsTxtResolver::new().map_err(AuthSetupError::HickoryResolverError)?; 96 - let client_config = OAuthClientConfig { 97 - client_metadata: AtprotoLocalhostClientMetadata { 98 - redirect_uris: Some(vec![String::from("http://127.0.0.1:9997/authorized")]), 99 - scopes: Some(READONLY_SCOPE.to_vec()), 100 - }, 101 - keys: None, 102 - resolver: OAuthResolverConfig { 103 - did_resolver: did_resolver(), 104 - handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 105 - dns_txt_resolver, 106 - http_client: Arc::clone(&http_client), 107 - }), 108 - authorization_server_metadata: Default::default(), 109 - protected_resource_metadata: Default::default(), 110 - }, 111 - state_store: MemoryStateStore::default(), 112 - session_store: MemorySessionStore::default(), 107 + 108 + let resolver = OAuthResolverConfig { 109 + did_resolver: did_resolver(), 110 + handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 111 + dns_txt_resolver, 112 + http_client: Arc::clone(&http_client), 113 + }), 114 + authorization_server_metadata: Default::default(), 115 + protected_resource_metadata: Default::default(), 113 116 }; 114 117 115 - let client = OAuthClient::new(client_config).map_err(AuthSetupError::AtriumClientError)?; 118 + let state_store = MemoryStateStore::default(); 119 + let session_store = MemorySessionStore::default(); 120 + 121 + let client = if let Some(path) = oauth_private_key { 122 + let key_contents: Vec<u8> = fs::read(path).unwrap(); 123 + let key_string = String::from_utf8(key_contents).unwrap(); 124 + let key = SecretKey::<p256::NistP256>::from_pkcs8_pem(&key_string) 125 + .map(|secret_key| Jwk { 126 + key: Key::from(&secret_key.into()), 127 + prm: Parameters { 128 + kid: Some("at-oauth-00".to_string()), 129 + cls: Some(Class::Signing), 130 + ..Default::default() 131 + }, 132 + }) 133 + .expect("to get private key"); 134 + OAuthClient::new(OAuthClientConfig { 135 + client_metadata: AtprotoClientMetadata { 136 + client_id: format!("{base}/client-metadata.json"), 137 + client_uri: Some(base.clone()), 138 + redirect_uris: vec![format!("{base}/authorized")], 139 + token_endpoint_auth_method: AuthMethod::PrivateKeyJwt, 140 + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 141 + scopes: READONLY_SCOPE.to_vec(), 142 + jwks_uri: Some(format!("{base}/.well-known/at-jwks.json")), 143 + token_endpoint_auth_signing_alg: Some(String::from("ES256")), 144 + }, 145 + keys: Some(vec![key]), 146 + resolver, 147 + state_store, 148 + session_store, 149 + }) 150 + .map_err(AuthSetupError::AtriumClientError)? 151 + } else { 152 + OAuthClient::new(OAuthClientConfig { 153 + client_metadata: AtprotoLocalhostClientMetadata { 154 + redirect_uris: Some(vec![String::from("http://127.0.0.1:9997/authorized")]), 155 + scopes: Some(READONLY_SCOPE.to_vec()), 156 + }, 157 + keys: None, 158 + resolver, 159 + state_store, 160 + session_store, 161 + }) 162 + .map_err(AuthSetupError::AtriumClientError)? 163 + }; 116 164 117 165 Ok(Self { 118 166 client: Arc::new(client), 119 167 did_resolver: Arc::new(did_resolver()), 120 168 }) 169 + } 170 + 171 + pub fn client_metadata(&self) -> OAuthClientMetadata { 172 + self.client.client_metadata.clone() 173 + } 174 + 175 + pub fn jwks(&self) -> JwkSet { 176 + self.client.jwks() 121 177 } 122 178 123 179 pub async fn begin(&self, handle: &str) -> Result<String, atrium_oauth::Error> {
+22 -2
who-am-i/src/server.rs
··· 1 1 use atrium_api::types::string::Did; 2 + use atrium_oauth::OAuthClientMetadata; 2 3 use axum::{ 3 4 Router, 4 5 extract::{FromRef, Json as ExtractJson, Query, State}, ··· 12 13 use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar}; 13 14 use axum_template::{RenderHtml, engine::Engine}; 14 15 use handlebars::{Handlebars, handlebars_helper}; 16 + use jose_jwk::JwkSet; 17 + use std::path::PathBuf; 15 18 16 19 use serde::Deserialize; 17 20 use serde_json::{Value, json}; ··· 52 55 } 53 56 } 54 57 58 + #[allow(clippy::too_many_arguments)] 55 59 pub async fn serve( 56 60 shutdown: CancellationToken, 57 61 app_secret: String, 62 + oauth_private_key: Option<PathBuf>, 58 63 tokens: Tokens, 64 + base: String, 65 + bind: String, 59 66 allowed_hosts: Vec<String>, 60 67 dev: bool, 61 68 ) { ··· 70 77 // clients have to pick up their identity-resolving tasks within this period 71 78 let task_pickup_expiration = Duration::from_secs(15); 72 79 73 - let oauth = OAuth::new().unwrap(); 80 + let oauth = OAuth::new(oauth_private_key, base).unwrap(); 74 81 75 82 let state = AppState { 76 83 engine: Engine::new(hbs), ··· 88 95 .route("/style.css", get(css)) 89 96 .route("/prompt", get(prompt)) 90 97 .route("/user-info", post(user_info)) 98 + .route("/client-metadata.json", get(client_metadata)) 91 99 .route("/auth", get(start_oauth)) 92 100 .route("/authorized", get(complete_oauth)) 93 101 .route("/disconnect", post(disconnect)) 102 + .route("/.well-known/at-jwks.json", get(at_jwks)) // todo combine jwks eps (key id is enough?) 94 103 .route("/.well-known/jwks.json", get(jwks)) 95 104 .with_state(state); 96 105 97 - let listener = TcpListener::bind("0.0.0.0:9997") 106 + eprintln!("starting server at http://{bind}"); 107 + let listener = TcpListener::bind(bind) 98 108 .await 99 109 .expect("listener binding to work"); 100 110 ··· 297 307 Json(json!({ "handle": handle })).into_response() 298 308 } 299 309 } 310 + } 311 + 312 + async fn client_metadata( 313 + State(AppState { oauth, .. }): State<AppState>, 314 + ) -> Json<OAuthClientMetadata> { 315 + Json(oauth.client_metadata()) 316 + } 317 + 318 + async fn at_jwks(State(AppState { oauth, .. }): State<AppState>) -> Json<JwkSet> { 319 + Json(oauth.jwks()) 300 320 } 301 321 302 322 #[derive(Debug, Deserialize)]