A library for ATProtocol identities.

feature: added atproto-oauth-axum crate

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

+4 -4
CLAUDE.prompts.md
··· 6 6 7 7 ## Review and ensure error correctness 8 8 9 - Review all of the errors in the `atproto-client` crate and ensure their names, messages, documentation, and usage are correct. Each error must have a globally unique identifier and error numbers must be ordered consistently. Think very very hard. 9 + Review all of the errors in the `atproto-oauth-axum` crate and ensure their names, messages, documentation, and usage are correct. Each error must have a globally unique identifier and error numbers must be ordered consistently. Think very very hard. 10 10 11 11 Review all of the errors and identify any that are unused. Think very very hard. 12 12 ··· 24 24 25 25 Write high level module documentation in the `path/to/file.rs` source file. Documentation should brief and specific. Think very hard about how to do this. 26 26 27 - Update the high level module documentation in each of the source files in the `atproto-identity`, `atproto-record`, `atproto-oauth`, `atproto-client` crates. Documentation should brief and specific. Think very hard about how to do this. 27 + Update the high level module documentation in each of the source files in the `atproto-identity`, `atproto-record`, `atproto-oauth`, `atproto-client`, and `atproto-oauth-axum` crates. Documentation should brief and specific. Think very hard about how to do this. 28 28 29 - Update the `README.md` files in the `atproto-identity`, `atproto-record`, `atproto-oauth`, and `atproto-client` crates. Each `README.md` file should include a high level overview of what the crate provides and include a summary of each binary produced by the crate. Think very hard. 29 + Update the `README.md` files in the `atproto-identity`, `atproto-record`, `atproto-oauth`, `atproto-oauth-axum`, and `atproto-client` crates. Each `README.md` file should include a high level overview of what the crate provides and include a summary of each binary produced by the crate. Think very hard. 30 30 31 31 Write a project `README.md` file that describes the project as a library that supports ATProtocol identity record signing and verifying. Note that parts of this was extracted from the open sourced https://tangled.sh/@smokesignal.events/smokesignal project. This project is open source under the MIT license. 32 32 33 - The `REAADME.md` file should provide a high level overview of both the `atproto-identity` and `atproto-record` crates. It should also concisely reference the available binaries and provide a minimal example of how to use them. 33 + The `README.md` file should provide a high level overview of all of the project crates. It should also concisely reference the available binaries and provide a minimal example of how to use them. 34 34 35 35 Write a `README.md` file for the `atproto-oauth` crate. Use `crates/atproto-identity/README.md` and `crates/atproto-record/README.md` as references. Think really hard. 36 36
+121
Cargo.lock
··· 80 80 dependencies = [ 81 81 "anyhow", 82 82 "async-trait", 83 + "axum", 83 84 "ecdsa", 84 85 "elliptic-curve", 85 86 "hickory-resolver", 87 + "http", 86 88 "k256", 87 89 "lru", 88 90 "multibase", ··· 104 106 "anyhow", 105 107 "async-trait", 106 108 "atproto-identity", 109 + "axum", 107 110 "base64", 108 111 "chrono", 109 112 "ecdsa", 110 113 "elliptic-curve", 114 + "http", 111 115 "k256", 112 116 "lru", 113 117 "multibase", ··· 127 131 ] 128 132 129 133 [[package]] 134 + name = "atproto-oauth-axum" 135 + version = "0.3.0" 136 + dependencies = [ 137 + "anyhow", 138 + "async-trait", 139 + "atproto-identity", 140 + "atproto-oauth", 141 + "atproto-record", 142 + "axum", 143 + "chrono", 144 + "elliptic-curve", 145 + "hickory-resolver", 146 + "http", 147 + "rand 0.8.5", 148 + "reqwest", 149 + "reqwest-chain", 150 + "reqwest-middleware", 151 + "serde", 152 + "serde_json", 153 + "thiserror 2.0.12", 154 + "tokio", 155 + "tracing", 156 + "urlencoding", 157 + ] 158 + 159 + [[package]] 130 160 name = "atproto-record" 131 161 version = "0.3.0" 132 162 dependencies = [ ··· 152 182 checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 153 183 154 184 [[package]] 185 + name = "axum" 186 + version = "0.8.4" 187 + source = "registry+https://github.com/rust-lang/crates.io-index" 188 + checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" 189 + dependencies = [ 190 + "axum-core", 191 + "axum-macros", 192 + "bytes", 193 + "form_urlencoded", 194 + "futures-util", 195 + "http", 196 + "http-body", 197 + "http-body-util", 198 + "hyper", 199 + "hyper-util", 200 + "itoa", 201 + "matchit", 202 + "memchr", 203 + "mime", 204 + "percent-encoding", 205 + "pin-project-lite", 206 + "rustversion", 207 + "serde", 208 + "serde_json", 209 + "serde_path_to_error", 210 + "serde_urlencoded", 211 + "sync_wrapper", 212 + "tokio", 213 + "tower", 214 + "tower-layer", 215 + "tower-service", 216 + "tracing", 217 + ] 218 + 219 + [[package]] 220 + name = "axum-core" 221 + version = "0.5.2" 222 + source = "registry+https://github.com/rust-lang/crates.io-index" 223 + checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" 224 + dependencies = [ 225 + "bytes", 226 + "futures-core", 227 + "http", 228 + "http-body", 229 + "http-body-util", 230 + "mime", 231 + "pin-project-lite", 232 + "rustversion", 233 + "sync_wrapper", 234 + "tower-layer", 235 + "tower-service", 236 + "tracing", 237 + ] 238 + 239 + [[package]] 240 + name = "axum-macros" 241 + version = "0.5.0" 242 + source = "registry+https://github.com/rust-lang/crates.io-index" 243 + checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" 244 + dependencies = [ 245 + "proc-macro2", 246 + "quote", 247 + "syn", 248 + ] 249 + 250 + [[package]] 155 251 name = "backtrace" 156 252 version = "0.3.75" 157 253 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 796 892 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 797 893 798 894 [[package]] 895 + name = "httpdate" 896 + version = "1.0.3" 897 + source = "registry+https://github.com/rust-lang/crates.io-index" 898 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 899 + 900 + [[package]] 799 901 name = "hyper" 800 902 version = "1.6.0" 801 903 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 808 910 "http", 809 911 "http-body", 810 912 "httparse", 913 + "httpdate", 811 914 "itoa", 812 915 "pin-project-lite", 813 916 "smallvec", ··· 1138 1241 ] 1139 1242 1140 1243 [[package]] 1244 + name = "matchit" 1245 + version = "0.8.4" 1246 + source = "registry+https://github.com/rust-lang/crates.io-index" 1247 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 1248 + 1249 + [[package]] 1141 1250 name = "memchr" 1142 1251 version = "2.7.4" 1143 1252 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1934 2043 ] 1935 2044 1936 2045 [[package]] 2046 + name = "serde_path_to_error" 2047 + version = "0.1.17" 2048 + source = "registry+https://github.com/rust-lang/crates.io-index" 2049 + checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" 2050 + dependencies = [ 2051 + "itoa", 2052 + "serde", 2053 + ] 2054 + 2055 + [[package]] 1937 2056 name = "serde_urlencoded" 1938 2057 version = "0.7.1" 1939 2058 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2257 2376 "tokio", 2258 2377 "tower-layer", 2259 2378 "tower-service", 2379 + "tracing", 2260 2380 ] 2261 2381 2262 2382 [[package]] ··· 2295 2415 source = "registry+https://github.com/rust-lang/crates.io-index" 2296 2416 checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 2297 2417 dependencies = [ 2418 + "log", 2298 2419 "pin-project-lite", 2299 2420 "tracing-attributes", 2300 2421 "tracing-core",
+1 -1
Cargo.toml
··· 3 3 "crates/atproto-record", 4 4 "crates/atproto-identity", 5 5 "crates/atproto-oauth", 6 - "crates/atproto-client", 6 + "crates/atproto-client", "crates/atproto-oauth-axum", 7 7 ] 8 8 resolver = "3" 9 9
+202 -92
README.md
··· 1 1 # AT Protocol Identity & Record Library 2 2 3 - A comprehensive Rust library for AT Protocol identity management and record signing/verification. This library provides full functionality for DID resolution, handle resolution, identity document management, and cryptographic record operations across multiple DID methods. 3 + A comprehensive Rust library for AT Protocol identity management, record signing, verification, and OAuth operations. This library provides full functionality for DID resolution, handle resolution, identity document management, cryptographic record operations, and OAuth 2.0 flows across multiple DID methods. 4 4 5 - Parts of this library were extracted from the open-sourced [Smokesignal](https://tangled.sh/@smokesignal.events/smokesignal) project. 5 + This project was extracted from the open-sourced [Smokesignal](https://tangled.sh/@smokesignal.events/smokesignal) project and is released under the MIT license. 6 + 7 + ## Project Overview 8 + 9 + This workspace provides a complete toolkit for AT Protocol operations including identity resolution, record signing/verification, OAuth 2.0 flows, HTTP client operations, and web server integration. The library is designed to be modular, secure, and fully compliant with AT Protocol specifications. 6 10 7 11 ## Crates 8 12 9 - This workspace contains two main crates: 13 + This workspace contains five specialized crates: 10 14 11 - ### `atproto-identity` 12 - 13 - A comprehensive AT Protocol identity management library providing: 15 + ### [`atproto-identity`](crates/atproto-identity/) 16 + **Core identity management and cryptographic operations** 14 17 15 18 - **DID Resolution**: Support for `did:plc`, `did:web`, and `did:key` methods 16 - - **Handle Resolution**: DNS-based handle resolution with validation 19 + - **Handle Resolution**: DNS and HTTP-based handle resolution with validation 17 20 - **Identity Documents**: Complete DID document parsing and management 18 - - **Cryptographic Operations**: P-256 and K-256 elliptic curve support 21 + - **Cryptographic Operations**: P-256 and K-256 elliptic curve support with signing/verification 22 + - **Key Management**: DID key identification, conversion, and JWK generation 19 23 - **Validation**: Input validation for handles and DIDs 20 - - **Configuration**: Environment-based configuration management 24 + - **Configuration**: Environment-based configuration with DNS and certificate customization 21 25 22 - ### `atproto-record` 26 + ### [`atproto-record`](crates/atproto-record/) 27 + **AT Protocol record signature operations** 23 28 24 - AT Protocol record signature operations library providing: 25 - 26 - - **Record Signing**: Create cryptographic signatures for AT Protocol records 29 + - **Record Signing**: Create cryptographic signatures for AT Protocol records with proper `$sig` object handling 27 30 - **Signature Verification**: Verify existing signatures against records and public keys 28 - - **IPLD Integration**: Proper IPLD DAG-CBOR serialization for signature content 29 - - **Multi-curve Support**: Support for P-256 and K-256 elliptic curves 31 + - **IPLD Integration**: Proper IPLD DAG-CBOR serialization for consistent signature generation 32 + - **Multi-curve Support**: Support for P-256 and K-256 elliptic curves via `atproto-identity` 33 + - **Repository Context**: Include repository and collection context in signatures 30 34 31 - ## CLI Tools 35 + ### [`atproto-oauth`](crates/atproto-oauth/) 36 + **Complete OAuth 2.0 operations with AT Protocol extensions** 32 37 33 - The library includes several command-line utilities: 38 + - **JWT Operations**: Complete JSON Web Token minting, verification, and validation with ES256/ES256K support 39 + - **JWK Management**: JSON Web Key generation and management for P-256 and K-256 curves 40 + - **PKCE Implementation**: RFC 7636 Proof Key for Code Exchange for secure OAuth flows 41 + - **DPoP Support**: RFC 9449 Demonstration of Proof-of-Possession with automatic retry middleware 42 + - **OAuth Resource Discovery**: RFC 8414 OAuth 2.0 authorization server and protected resource metadata discovery 43 + - **AT Protocol Validation**: Comprehensive validation of OAuth servers against AT Protocol requirements 44 + 45 + ### [`atproto-client`](crates/atproto-client/) 46 + **HTTP client library for AT Protocol services** 47 + 48 + - **HTTP Client Operations**: Authenticated and unauthenticated HTTP GET/POST requests with JSON support 49 + - **DPoP Authentication**: RFC 9449 Demonstration of Proof-of-Possession with automatic retry middleware 50 + - **Repository Operations**: Complete CRUD operations for AT Protocol repository records 51 + - **URL Building**: Flexible URL construction with parameter encoding and query string generation 52 + - **OAuth Integration**: Seamless integration with `atproto-oauth` for DPoP authentication 53 + 54 + ### [`atproto-oauth-axum`](crates/atproto-oauth-axum/) 55 + **Axum web handlers for OAuth 2.0 authorization server endpoints** 56 + 57 + - **Complete OAuth Server Handlers**: Ready-to-use Axum handlers for all required OAuth 2.0 endpoints 58 + - **Client Metadata Endpoint**: RFC 7591 compliant client metadata for dynamic client registration 59 + - **JWKS Endpoint**: JSON Web Key Set serving for JWT signature verification 60 + - **Authorization Callback Handler**: Complete OAuth callback processing with token exchange 61 + - **OAuth Login CLI Tool**: Full-featured command-line tool for testing and development OAuth flows 34 62 35 - ### atproto-identity 63 + ## Command Line Tools 36 64 37 - - `atproto-identity-resolve` - Resolve DIDs and handles to identity documents 38 - - `atproto-identity-sign` - Sign identity-related operations 39 - - `atproto-identity-validate` - Validate DID and handle formats 65 + The library includes 7 command-line utilities across the crates: 66 + 67 + ### Identity Operations (`atproto-identity`) 68 + - **`atproto-identity-resolve`** - Resolve AT Protocol handles and DIDs to identity documents 69 + - **`atproto-identity-sign`** - Sign JSON data with DID keys using IPLD DAG-CBOR serialization 70 + - **`atproto-identity-validate`** - Verify cryptographic signatures of JSON data 71 + - **`atproto-identity-key`** - Generate and manage cryptographic keys (P-256/K-256) 40 72 41 - ### atproto-record 73 + ### Record Operations (`atproto-record`) 74 + - **`atproto-record-sign`** - Sign AT Protocol records with embedded signature metadata 75 + - **`atproto-record-verify`** - Verify AT Protocol record signatures with issuer authentication 42 76 43 - - `atproto-record-sign` - Sign AT Protocol records from files or stdin 44 - - `atproto-record-verify` - Verify AT Protocol record signatures from files or stdin 77 + ### OAuth Operations (`atproto-oauth-axum`) 78 + - **`atproto-oauth-login`** - Complete OAuth client flow with local server and token acquisition 45 79 46 80 ## Quick Start 47 81 ··· 50 84 ```toml 51 85 [dependencies] 52 86 atproto-identity = "0.3.0" 53 - atproto-record = "0.3.0" 87 + atproto-record = "0.3.0" 88 + atproto-oauth = "0.3.0" 89 + atproto-client = "0.3.0" 90 + atproto-oauth-axum = "0.3.0" 54 91 ``` 55 92 56 93 ### Basic Identity Resolution 57 94 58 95 ```rust 59 - use atproto_identity::resolve::resolve_handle; 96 + use atproto_identity::resolve::{resolve_subject, create_resolver}; 60 97 61 98 #[tokio::main] 62 99 async fn main() -> anyhow::Result<()> { 100 + let http_client = reqwest::Client::new(); 101 + let dns_resolver = create_resolver(&[]); 102 + 63 103 // Resolve a handle to a DID 64 - let did = resolve_handle("alice.bsky.social").await?; 104 + let did = resolve_subject(&http_client, &dns_resolver, "alice.bsky.social").await?; 65 105 println!("Resolved DID: {}", did); 66 106 67 107 Ok(()) ··· 78 118 #[tokio::main] 79 119 async fn main() -> anyhow::Result<()> { 80 120 // Parse DID key for signing operations 81 - let signing_key = identify_key("did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW")?; 121 + let signing_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 82 122 83 123 // Create a record to sign 84 124 let record = json!({ ··· 89 129 90 130 // Create signature object with issuer and timestamp 91 131 let signature_object = json!({ 92 - "issuer": "did:plc:tgudj2fjm77pzkuawquqhsxm", 93 - "issued_at": "2024-01-01T00:00:00.000Z" 132 + "issuer": "did:plc:issuer123", 133 + "issuedAt": "2024-01-01T00:00:00.000Z" 94 134 }); 95 135 96 136 // Sign the record 97 137 let signed_record = signature::create( 98 138 &signing_key, 99 139 &record, 100 - "did:plc:4zutorghlchjxzgceklue4la", // repository 101 - "app.bsky.feed.post", // collection 140 + "did:plc:user123", // repository 141 + "app.bsky.feed.post", // collection 102 142 signature_object, 103 143 ).await?; 104 144 105 145 // Verify the signature 106 146 signature::verify( 107 - "did:plc:tgudj2fjm77pzkuawquqhsxm", // issuer 108 - &signing_key, // verification key 109 - signed_record, // signed record 110 - "did:plc:4zutorghlchjxzgceklue4la", // repository 111 - "app.bsky.feed.post", // collection 147 + "did:plc:issuer123", // issuer 148 + &signing_key, // verification key 149 + signed_record, // signed record 150 + "did:plc:user123", // repository 151 + "app.bsky.feed.post", // collection 112 152 ).await?; 113 153 114 154 println!("Signature verification successful"); ··· 117 157 } 118 158 ``` 119 159 120 - ### CLI Usage Examples 160 + ### OAuth Operations 161 + 162 + ```rust 163 + use atproto_oauth::jwt::{mint, Header, Claims, JoseClaims}; 164 + use atproto_oauth::pkce; 165 + use atproto_identity::key::identify_key; 166 + 167 + #[tokio::main] 168 + async fn main() -> anyhow::Result<()> { 169 + let key_data = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 170 + 171 + // Generate PKCE parameters 172 + let (code_verifier, code_challenge) = pkce::generate(); 173 + println!("PKCE Challenge: {}", code_challenge); 174 + 175 + // Create and sign JWT 176 + let header = Header::default(); 177 + let claims = Claims::new(JoseClaims { 178 + issuer: Some("did:plc:issuer123".to_string()), 179 + subject: Some("did:plc:subject456".to_string()), 180 + audience: Some("https://pds.example.com".to_string()), 181 + ..Default::default() 182 + }); 183 + 184 + let token = mint(&key_data, &header, &claims)?; 185 + println!("JWT: {}", token); 186 + 187 + Ok(()) 188 + } 189 + ``` 190 + 191 + ## CLI Usage Examples 121 192 122 193 ```bash 123 - # Resolve a handle or DID 124 - cargo run --bin atproto-identity-resolve -- alice.bsky.social 194 + # Resolve a handle or DID to identity document 195 + cargo run --bin atproto-identity-resolve alice.bsky.social 125 196 126 - # Get full DID document 127 - cargo run --bin atproto-identity-resolve -- --did-document did:plc:abc123 197 + # Get full DID document with verification methods 198 + cargo run --bin atproto-identity-resolve --did-document did:plc:user123 128 199 129 - # Sign a record from file 130 - cargo run --bin atproto-record-sign -- \ 131 - did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \ 132 - ./record.json \ 133 - did:plc:tgudj2fjm77pzkuawquqhsxm \ 134 - repository=did:plc:4zutorghlchjxzgceklue4la \ 135 - collection=app.bsky.feed.post 200 + # Generate a new P-256 private key 201 + cargo run --bin atproto-identity-key generate p256 136 202 137 - # Sign a record from stdin 138 - echo '{"$type":"app.bsky.feed.post","text":"Hello!"}' | \ 139 - cargo run --bin atproto-record-sign -- \ 140 - did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \ 141 - -- \ 142 - did:plc:tgudj2fjm77pzkuawquqhsxm \ 143 - repository=did:plc:4zutorghlchjxzgceklue4la \ 203 + # Sign a record from file with all required context 204 + cargo run --bin atproto-record-sign \ 205 + did:key:zQ3shNzMp4oaaQ1... \ 206 + did:plc:issuer123 \ 207 + record.json \ 208 + repository=did:plc:user123 \ 144 209 collection=app.bsky.feed.post 145 210 146 - # Verify a signature from file 147 - cargo run --bin atproto-record-verify -- \ 148 - did:plc:tgudj2fjm77pzkuawquqhsxm \ 149 - did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \ 150 - ./signed_record.json \ 151 - repository=did:plc:4zutorghlchjxzgceklue4la \ 211 + # Verify a signed record 212 + cargo run --bin atproto-record-verify \ 213 + did:plc:issuer123 \ 214 + did:key:zQ3shNzMp4oaaQ1... \ 215 + signed_record.json \ 216 + repository=did:plc:user123 \ 152 217 collection=app.bsky.feed.post 153 218 154 - # Verify a signature from stdin 155 - echo '{"signatures":[...],"text":"Hello!"}' | \ 156 - cargo run --bin atproto-record-verify -- \ 157 - did:plc:tgudj2fjm77pzkuawquqhsxm \ 158 - did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \ 159 - -- \ 160 - repository=did:plc:4zutorghlchjxzgceklue4la \ 161 - collection=app.bsky.feed.post 219 + # Start OAuth login flow (requires EXTERNAL_BASE environment variable) 220 + EXTERNAL_BASE=localhost:8080 cargo run --bin atproto-oauth-login login \ 221 + did:key:zQ3shNzMp4oaaQ1... \ 222 + alice.bsky.social 223 + ``` 224 + 225 + ## Features 226 + 227 + - **Async/Await**: Built with modern Rust async patterns using Tokio 228 + - **Error Handling**: Comprehensive structured error types using `thiserror` with standardized error codes 229 + - **Security**: Forbids unsafe code, follows security best practices, and implements all required OAuth security extensions 230 + - **Standards Compliance**: Full AT Protocol, RFC 7636 (PKCE), RFC 9449 (DPoP), and RFC 8414 (OAuth Discovery) compliance 231 + - **Logging**: Structured logging with `tracing` for debugging and monitoring 232 + - **Multi-platform**: Works on all major platforms with configurable DNS and HTTP settings 233 + - **Modular Design**: Clean separation of concerns allowing selective usage of components 234 + 235 + ## Architecture 236 + 237 + The library follows a layered architecture with clear separation of concerns: 238 + 239 + ``` 240 + ┌─────────────────────────────────────────────────────────────┐ 241 + │ Application Layer │ 242 + │ (CLI Tools & Web Handlers) │ 243 + ├─────────────────────────────────────────────────────────────┤ 244 + │ Protocol Layer │ 245 + │ (atproto-client, atproto-record) │ 246 + ├─────────────────────────────────────────────────────────────┤ 247 + │ OAuth Layer │ 248 + │ (atproto-oauth, atproto-oauth-axum) │ 249 + ├─────────────────────────────────────────────────────────────┤ 250 + │ Foundation Layer │ 251 + │ (atproto-identity - Core Services) │ 252 + └─────────────────────────────────────────────────────────────┘ 162 253 ``` 163 254 255 + - **Foundation Layer**: Core identity, cryptographic, and DID operations 256 + - **OAuth Layer**: Complete OAuth 2.0 flows with AT Protocol extensions 257 + - **Protocol Layer**: Higher-level AT Protocol operations (records, client) 258 + - **Application Layer**: Ready-to-use tools and web framework integration 259 + 164 260 ## Development 165 261 166 - ### Building 262 + ### Building the Project 167 263 168 264 ```bash 265 + # Build all crates 169 266 cargo build 267 + 268 + # Build specific crate 269 + cargo build -p atproto-identity 270 + 271 + # Build with all features 272 + cargo build --all-features 170 273 ``` 171 274 172 275 ### Running Tests 173 276 174 277 ```bash 278 + # Run all tests 175 279 cargo test 280 + 281 + # Run tests for specific crate 282 + cargo test -p atproto-oauth 283 + 284 + # Run with output 285 + cargo test -- --nocapture 176 286 ``` 177 287 178 288 ### Code Quality ··· 181 291 # Format code 182 292 cargo fmt 183 293 184 - # Lint 294 + # Lint code 185 295 cargo clippy 186 296 187 297 # Check without building 188 298 cargo check 299 + 300 + # Run all quality checks 301 + cargo fmt && cargo clippy && cargo test 189 302 ``` 190 303 191 - ## Features 304 + ### Documentation 192 305 193 - - **Async/Await**: Built with modern Rust async patterns using Tokio 194 - - **Error Handling**: Comprehensive structured error types using `thiserror` 195 - - **Logging**: Structured logging with `tracing` 196 - - **Security**: Forbids unsafe code and follows security best practices 197 - - **Standards Compliance**: Full AT Protocol specification compliance 198 - - **Multi-platform**: Works on all major platforms 199 - 200 - ## Architecture 201 - 202 - The library follows a modular architecture with clear separation of concerns: 306 + ```bash 307 + # Generate documentation 308 + cargo doc --open 203 309 204 - - **Identity Management**: Handle DID resolution, validation, and document management 205 - - **Cryptographic Operations**: Secure key operations and signature handling 206 - - **Network Operations**: HTTP and DNS resolution with proper error handling 207 - - **Data Models**: Comprehensive type definitions for AT Protocol entities 208 - - **CLI Tools**: Ready-to-use command-line utilities 310 + # Generate documentation for all crates 311 + cargo doc --workspace --open 312 + ``` 209 313 210 314 ## License 211 315 212 - MIT License - see [LICENSE](LICENSE) for details. 316 + This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 213 317 214 318 ## Contributing 215 319 216 - Contributions are welcome! This project follows standard Rust conventions and includes comprehensive testing and documentation requirements. 320 + Contributions are welcome! Please ensure that: 217 321 218 - ## Repository 322 + 1. All tests pass: `cargo test` 323 + 2. Code is properly formatted: `cargo fmt` 324 + 3. No linting issues: `cargo clippy` 325 + 4. New functionality includes appropriate tests and documentation 326 + 5. Error handling follows the project's structured error format 219 327 220 - https://tangled.sh/@smokesignal.events/atproto-identity-rs 328 + ## Acknowledgments 329 + 330 + This library was extracted from the [Smokesignal](https://tangled.sh/@smokesignal.events/smokesignal) project, an open-source event and RSVP management and discovery application. We thank the Smokesignal contributors for their foundational work on AT Protocol identity management and record operations.
+6 -5
crates/atproto-client/README.md
··· 99 99 ```rust 100 100 use atproto_client::com::atproto::repo::{ 101 101 get_record, list_records, create_record, put_record, 102 - CreateRecordRequest, PutRecordRequest 102 + CreateRecordRequest, PutRecordRequest, ListRecordsParams 103 103 }; 104 104 use atproto_client::client::DPoPAuth; 105 105 use atproto_identity::key::identify_key; ··· 128 128 None // Optional CID for specific version 129 129 ).await?; 130 130 131 - // List records in a collection 131 + // List records in a collection with parameters 132 132 let list_response = list_records::<serde_json::Value>( 133 133 &http_client, 134 134 &dpop_auth, 135 135 pds_url, 136 136 "did:plc:user123".to_string(), 137 137 "app.bsky.feed.post".to_string(), 138 - Some(50), // limit 139 - None, // cursor for pagination 140 - Some(false) // reverse order 138 + ListRecordsParams::new() 139 + .limit(50) 140 + .reverse(false) 141 141 ).await?; 142 142 143 143 // Create a new record ··· 232 232 233 233 ### Request/Response Types 234 234 235 + - **`ListRecordsParams`**: Builder-style parameters for listing records with pagination 235 236 - **`CreateRecordRequest<T>`**: Strongly-typed request for creating new records 236 237 - **`PutRecordRequest<T>`**: Strongly-typed request for updating records 237 238 - **`GetRecordResponse`**: Response containing record data, URI, and CID
+154 -116
crates/atproto-client/src/com_atproto_repo.rs
··· 31 31 use serde::{de::DeserializeOwned, Deserialize, Serialize}; 32 32 33 33 use crate::{ 34 - client::{get_dpop_json, post_dpop_json, DPoPAuth}, 34 + client::{get_dpop_json, get_json, post_dpop_json, DPoPAuth}, 35 35 errors::SimpleError, 36 36 url::URLBuilder, 37 37 }; ··· 57 57 Error(SimpleError), 58 58 } 59 59 60 - /// Request to create a new record in an AT Protocol repository. 61 - #[derive(Debug, Serialize, Deserialize, Clone)] 62 - #[serde(bound = "T: Serialize + DeserializeOwned")] 63 - pub struct CreateRecordRequest<T: DeserializeOwned> { 64 - /// Repository identifier (DID) 65 - pub repo: String, 66 - /// Collection NSID (e.g., "app.bsky.feed.post") 67 - pub collection: String, 68 - 69 - /// Optional record key; if None, server will generate one 70 - #[serde(skip_serializing_if = "Option::is_none", default, rename = "rkey")] 71 - pub record_key: Option<String>, 72 - 73 - /// Whether to validate the record against its schema 74 - pub validate: bool, 75 - 76 - /// The record content to create 77 - pub record: T, 78 - 79 - /// Optional commit CID to swap (for atomic updates) 80 - #[serde( 81 - skip_serializing_if = "Option::is_none", 82 - default, 83 - rename = "swapCommit" 84 - )] 85 - pub swap_commit: Option<String>, 86 - } 87 - 88 - /// Request to update an existing record in an AT Protocol repository. 89 - #[derive(Debug, Serialize, Deserialize, Clone)] 90 - #[serde(bound = "T: Serialize + DeserializeOwned")] 91 - pub struct PutRecordRequest<T: DeserializeOwned> { 92 - /// Repository identifier (DID) 93 - pub repo: String, 94 - /// Collection NSID (e.g., "app.bsky.feed.post") 95 - pub collection: String, 96 - 97 - /// Record key to update 98 - #[serde(rename = "rkey")] 99 - pub record_key: String, 100 - 101 - /// Whether to validate the record against its schema 102 - pub validate: bool, 103 - 104 - /// The new record content 105 - pub record: T, 106 - 107 - /// Optional commit CID to swap (for atomic updates) 108 - #[serde( 109 - skip_serializing_if = "Option::is_none", 110 - default, 111 - rename = "swapCommit" 112 - )] 113 - pub swap_commit: Option<String>, 114 - 115 - /// Optional record CID to swap (for conditional updates) 116 - #[serde( 117 - skip_serializing_if = "Option::is_none", 118 - default, 119 - rename = "swapRecord" 120 - )] 121 - pub swap_record: Option<String>, 122 - } 123 - 124 - /// Response from creating a record in an AT Protocol repository. 125 - #[derive(Debug, Deserialize, Clone)] 126 - #[serde(untagged)] 127 - pub enum CreateRecordResponse { 128 - /// Successfully created record reference 129 - StrongRef { 130 - /// AT-URI of the created record 131 - uri: String, 132 - /// Content identifier (CID) of the created record 133 - cid: String, 134 - 135 - /// Additional fields not part of the standard response 136 - #[serde(flatten)] 137 - extra: HashMap<String, serde_json::Value>, 138 - }, 139 - /// Error response from the server 140 - Error(SimpleError), 141 - } 142 - 143 - /// Response from updating a record in an AT Protocol repository. 144 - #[derive(Debug, Serialize, Deserialize, Clone)] 145 - #[serde(untagged)] 146 - pub enum PutRecordResponse { 147 - /// Successfully updated record reference 148 - StrongRef { 149 - /// AT-URI of the updated record 150 - uri: String, 151 - /// Content identifier (CID) of the updated record 152 - cid: String, 153 - 154 - /// Additional fields not part of the standard response 155 - #[serde(flatten)] 156 - extra: HashMap<String, serde_json::Value>, 157 - }, 158 - /// Error response from the server 159 - Error(SimpleError), 160 - } 161 - 162 60 /// Retrieves a record from an AT Protocol repository. 163 61 /// 164 62 /// # Arguments ··· 176 74 /// The record data or an error response 177 75 pub async fn get_record( 178 76 http_client: &reqwest::Client, 179 - dpop_auth: &DPoPAuth, 77 + dpop_auth: Option<&DPoPAuth>, 180 78 base_url: &str, 181 79 repo: &str, 182 80 collection: &str, ··· 198 96 199 97 tracing::info!(?url, "get_record"); 200 98 201 - get_dpop_json(http_client, dpop_auth, &url) 202 - .await 203 - .and_then(|value| serde_json::from_value(value).map_err(|err| err.into())) 99 + if let Some(dpop_auth) = dpop_auth { 100 + get_dpop_json(http_client, dpop_auth, &url) 101 + .await 102 + .and_then(|value| serde_json::from_value(value).map_err(|err| err.into())) 103 + } else { 104 + get_json(http_client, &url) 105 + .await 106 + .and_then(|value| serde_json::from_value(value).map_err(|err| err.into())) 107 + } 204 108 } 205 109 206 110 /// A single record in a list records response. ··· 223 127 pub records: Vec<ListRecord<T>>, 224 128 } 225 129 130 + /// Parameters for listing records from a repository collection. 131 + #[derive(Default)] 132 + pub struct ListRecordsParams { 133 + /// Maximum number of records to return 134 + pub limit: Option<u32>, 135 + /// Pagination cursor from previous request 136 + pub cursor: Option<String>, 137 + /// Whether to return records in reverse chronological order 138 + pub reverse: Option<bool>, 139 + } 140 + 141 + impl ListRecordsParams { 142 + /// Creates new list records parameters with default values. 143 + pub fn new() -> Self { 144 + Self::default() 145 + } 146 + 147 + /// Sets the maximum number of records to return. 148 + pub fn limit(mut self, limit: u32) -> Self { 149 + self.limit = Some(limit); 150 + self 151 + } 152 + 153 + /// Sets the pagination cursor. 154 + pub fn cursor(mut self, cursor: String) -> Self { 155 + self.cursor = Some(cursor); 156 + self 157 + } 158 + 159 + /// Sets reverse chronological ordering. 160 + pub fn reverse(mut self, reverse: bool) -> Self { 161 + self.reverse = Some(reverse); 162 + self 163 + } 164 + } 165 + 226 166 /// Lists records from an AT Protocol repository collection. 227 167 /// 228 168 /// # Arguments ··· 232 172 /// * `base_url` - Base URL of the AT Protocol server 233 173 /// * `repo` - Repository identifier (DID) 234 174 /// * `collection` - Collection NSID to list from 235 - /// * `limit` - Maximum number of records to return 236 - /// * `cursor` - Pagination cursor from previous request 237 - /// * `reverse` - Whether to return records in reverse chronological order 175 + /// * `params` - Optional parameters for listing (limit, cursor, reverse) 238 176 /// 239 177 /// # Returns 240 178 /// ··· 245 183 base_url: &str, 246 184 repo: String, 247 185 collection: String, 248 - limit: Option<u32>, 249 - cursor: Option<String>, 250 - reverse: Option<bool>, 186 + params: ListRecordsParams, 251 187 ) -> Result<ListRecordsResponse<T>> { 252 188 let mut url_builder = URLBuilder::new(base_url); 253 189 url_builder.path("/xrpc/com.atproto.repo.listRecords"); ··· 256 192 url_builder.param("repo", &repo); 257 193 url_builder.param("collection", &collection); 258 194 259 - if let Some(limit) = limit { 195 + if let Some(limit) = params.limit { 260 196 url_builder.param("limit", &limit.to_string()); 261 197 } 262 198 263 - if let Some(cursor) = cursor { 199 + if let Some(cursor) = params.cursor { 264 200 url_builder.param("cursor", &cursor); 265 201 } 266 202 267 - if let Some(reverse) = reverse { 203 + if let Some(reverse) = params.reverse { 268 204 url_builder.param("reverse", &reverse.to_string()); 269 205 } 270 206 ··· 277 213 .and_then(|value| serde_json::from_value(value).map_err(|err| err.into())) 278 214 } 279 215 216 + /// Request to create a new record in an AT Protocol repository. 217 + #[derive(Debug, Serialize, Deserialize, Clone)] 218 + #[serde(bound = "T: Serialize + DeserializeOwned")] 219 + pub struct CreateRecordRequest<T: DeserializeOwned> { 220 + /// Repository identifier (DID) 221 + pub repo: String, 222 + /// Collection NSID (e.g., "app.bsky.feed.post") 223 + pub collection: String, 224 + 225 + /// Optional record key; if None, server will generate one 226 + #[serde(skip_serializing_if = "Option::is_none", default, rename = "rkey")] 227 + pub record_key: Option<String>, 228 + 229 + /// Whether to validate the record against its schema 230 + pub validate: bool, 231 + 232 + /// The record content to create 233 + pub record: T, 234 + 235 + /// Optional commit CID to swap (for atomic updates) 236 + #[serde( 237 + skip_serializing_if = "Option::is_none", 238 + default, 239 + rename = "swapCommit" 240 + )] 241 + pub swap_commit: Option<String>, 242 + } 243 + 244 + /// Response from creating a record in an AT Protocol repository. 245 + #[derive(Debug, Deserialize, Clone)] 246 + #[serde(untagged)] 247 + pub enum CreateRecordResponse { 248 + /// Successfully created record reference 249 + StrongRef { 250 + /// AT-URI of the created record 251 + uri: String, 252 + /// Content identifier (CID) of the created record 253 + cid: String, 254 + 255 + /// Additional fields not part of the standard response 256 + #[serde(flatten)] 257 + extra: HashMap<String, serde_json::Value>, 258 + }, 259 + /// Error response from the server 260 + Error(SimpleError), 261 + } 262 + 280 263 /// Creates a new record in an AT Protocol repository. 281 264 /// 282 265 /// # Arguments ··· 306 289 post_dpop_json(http_client, dpop_auth, &url, value) 307 290 .await 308 291 .and_then(|value| serde_json::from_value(value).map_err(|err| err.into())) 292 + } 293 + 294 + /// Request to update an existing record in an AT Protocol repository. 295 + #[derive(Debug, Serialize, Deserialize, Clone)] 296 + #[serde(bound = "T: Serialize + DeserializeOwned")] 297 + pub struct PutRecordRequest<T: DeserializeOwned> { 298 + /// Repository identifier (DID) 299 + pub repo: String, 300 + /// Collection NSID (e.g., "app.bsky.feed.post") 301 + pub collection: String, 302 + 303 + /// Record key to update 304 + #[serde(rename = "rkey")] 305 + pub record_key: String, 306 + 307 + /// Whether to validate the record against its schema 308 + pub validate: bool, 309 + 310 + /// The new record content 311 + pub record: T, 312 + 313 + /// Optional commit CID to swap (for atomic updates) 314 + #[serde( 315 + skip_serializing_if = "Option::is_none", 316 + default, 317 + rename = "swapCommit" 318 + )] 319 + pub swap_commit: Option<String>, 320 + 321 + /// Optional record CID to swap (for conditional updates) 322 + #[serde( 323 + skip_serializing_if = "Option::is_none", 324 + default, 325 + rename = "swapRecord" 326 + )] 327 + pub swap_record: Option<String>, 328 + } 329 + 330 + /// Response from updating a record in an AT Protocol repository. 331 + #[derive(Debug, Serialize, Deserialize, Clone)] 332 + #[serde(untagged)] 333 + pub enum PutRecordResponse { 334 + /// Successfully updated record reference 335 + StrongRef { 336 + /// AT-URI of the updated record 337 + uri: String, 338 + /// Content identifier (CID) of the updated record 339 + cid: String, 340 + 341 + /// Additional fields not part of the standard response 342 + #[serde(flatten)] 343 + extra: HashMap<String, serde_json::Value>, 344 + }, 345 + /// Error response from the server 346 + Error(SimpleError), 309 347 } 310 348 311 349 /// Updates an existing record in an AT Protocol repository.
+5
crates/atproto-identity/Cargo.toml
··· 55 55 async-trait = "0.1.88" 56 56 lru = { workspace = true, optional = true } 57 57 58 + axum = { version = "0.8", optional = true } 59 + http = { version = "1.0.0", optional = true } 60 + 58 61 [features] 62 + default = ["lru", "axum"] 59 63 lru = ["dep:lru"] 64 + axum = ["dep:axum", "dep:http"] 60 65 61 66 [lints] 62 67 workspace = true
+70 -14
crates/atproto-identity/README.md
··· 142 142 143 143 ## Command Line Tools 144 144 145 - The library includes several command-line tools for AT Protocol identity operations: 145 + The library includes four command-line tools for AT Protocol identity operations: 146 146 147 147 ### `atproto-identity-resolve` 148 - Resolves AT Protocol handles and DIDs to their canonical DID identifiers and optionally retrieves full DID documents. Supports both did:plc and did:web methods with configurable DNS and HTTP settings. 148 + 149 + Resolves AT Protocol handles and DIDs to their canonical DID identifiers and optionally retrieves full DID documents. This tool supports both `did:plc` and `did:web` methods with configurable DNS and HTTP settings for comprehensive identity resolution. 150 + 151 + **Features:** 152 + - **Handle Resolution**: Converts AT Protocol handles (e.g., `alice.bsky.social`) to DIDs 153 + - **DID Document Retrieval**: Fetches complete DID documents with verification methods 154 + - **Multi-Method Support**: Handles both PLC directory and Web DID resolution 155 + - **DNS Configuration**: Supports custom DNS nameservers and certificate bundles 156 + - **Output Options**: Choose between DID-only output or full document retrieval 149 157 150 158 ```bash 151 - # Resolve a handle to DID 152 - cargo run --bin atproto-identity-resolve ngerakines.me 159 + # Resolve a handle to its DID 160 + cargo run --bin atproto-identity-resolve alice.bsky.social 153 161 154 - # Get full DID document 155 - cargo run --bin atproto-identity-resolve --did-document ngerakines.me 162 + # Resolve a DID and get full DID document 163 + cargo run --bin atproto-identity-resolve --did-document did:plc:user123 164 + 165 + # Resolve with custom configuration 166 + PLC_HOSTNAME=plc.directory DNS_NAMESERVERS="8.8.8.8;1.1.1.1" \ 167 + cargo run --bin atproto-identity-resolve --did-document alice.bsky.social 156 168 ``` 157 169 158 170 ### `atproto-identity-sign` 159 - Creates cryptographic signatures of JSON data using AT Protocol DID keys. Takes a JSON file, serializes it using IPLD DAG-CBOR format, and produces a multibase-encoded signature. 171 + 172 + Creates cryptographic signatures of JSON data using AT Protocol DID keys. This tool reads JSON files, serializes them using IPLD DAG-CBOR format for consistency, and produces multibase-encoded signatures suitable for AT Protocol operations. 173 + 174 + **Features:** 175 + - **DID Key Support**: Works with both P-256 and K-256 private keys 176 + - **JSON Processing**: Handles arbitrary JSON data structures 177 + - **IPLD Serialization**: Uses DAG-CBOR for deterministic serialization 178 + - **Multibase Output**: Produces signatures in multibase format 179 + - **File Input**: Reads JSON data from specified files 160 180 161 181 ```bash 162 - # Sign a JSON file with a DID key 163 - cargo run --bin atproto-identity-sign did:key:zQ3sh... record.json 182 + # Sign a JSON file with a DID private key 183 + cargo run --bin atproto-identity-sign did:key:zQ3shNzMp4oaaQ1... data.json 184 + 185 + # Example output: multibase-encoded signature string 186 + # uEiB5vJz8aZhpx3bY2nKfRzPpLmQwA8Z9qXhNvYtF2gH7... 164 187 ``` 165 188 166 189 ### `atproto-identity-validate` 167 - Verifies cryptographic signatures of JSON data using AT Protocol DID keys. Takes a JSON file, a multibase-encoded signature, and a DID key, then validates the signature. 190 + 191 + Verifies cryptographic signatures of JSON data using AT Protocol DID keys. This tool validates that signatures were created by the holder of a specific private key, ensuring data integrity and authenticity for AT Protocol operations. 192 + 193 + **Features:** 194 + - **Signature Verification**: Validates multibase-encoded signatures 195 + - **DID Key Support**: Works with P-256 and K-256 public keys 196 + - **IPLD Deserialization**: Uses DAG-CBOR for consistent verification 197 + - **File Processing**: Reads JSON data and signatures from files 198 + - **Exit Code Reporting**: Returns appropriate exit codes for success/failure 168 199 169 200 ```bash 170 201 # Validate a signature against a JSON file 171 - cargo run --bin atproto-identity-validate did:key:zQ3sh... record.json uEiB... 202 + cargo run --bin atproto-identity-validate did:key:zQ3shNzMp4oaaQ1... data.json uEiB5vJz8aZ... 203 + 204 + # Successful validation returns exit code 0 205 + # Failed validation returns exit code 1 172 206 ``` 173 207 208 + **Arguments:** 209 + - `<public_key>` - DID key string for verification (did:key:...) 210 + - `<data_file>` - JSON file containing the original data 211 + - `<signature>` - Multibase-encoded signature to verify 212 + 174 213 ### `atproto-identity-key` 175 - Provides cryptographic key management capabilities including key generation for both P-256 and K-256 elliptic curves. 214 + 215 + Provides comprehensive cryptographic key management capabilities for AT Protocol operations, including key generation and inspection for both P-256 and K-256 elliptic curves. 216 + 217 + **Features:** 218 + - **Key Generation**: Creates new private keys for both curve types 219 + - **Secure Random**: Uses cryptographically secure random number generation 220 + - **DID Key Format**: Outputs keys in standard DID key format 221 + - **Multiple Algorithms**: Supports both P-256 (secp256r1) and K-256 (secp256k1) curves 222 + - **Development Ready**: Generated keys ready for use in AT Protocol operations 176 223 177 224 ```bash 178 - # Generate a new P-256 private key 225 + # Generate a new P-256 private key (recommended for most AT Protocol use) 179 226 cargo run --bin atproto-identity-key generate p256 180 227 181 - # Generate a new K-256 private key 228 + # Generate a new K-256 private key (Bitcoin-style curve) 182 229 cargo run --bin atproto-identity-key generate k256 230 + 231 + # Example output format: 232 + # did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA 183 233 ``` 234 + 235 + **Key Types:** 236 + - **P-256**: NIST P-256 curve (secp256r1) - Recommended for new applications 237 + - **K-256**: secp256k1 curve (same as Bitcoin) - For Bitcoin-compatible operations 238 + 239 + **Security Note:** Generated keys are output to stdout and should be stored securely. Never commit private keys to version control or share them publicly. 184 240 185 241 ## Architecture 186 242
+6
crates/atproto-identity/src/axum/mod.rs
··· 1 + //! Axum web framework integration for AT Protocol identity management. 2 + //! 3 + //! Provides request extractors and state management for integrating AT Protocol 4 + //! identity operations with Axum web applications. 5 + 6 + pub mod state;
+49
crates/atproto-identity/src/axum/state.rs
··· 1 + //! Axum request extractors for AT Protocol identity services. 2 + //! 3 + //! Provides extractors that automatically inject DID document storage and key providers 4 + //! into Axum request handlers from application state. 5 + 6 + use axum::extract::{FromRef, FromRequestParts}; 7 + use http::request::Parts; 8 + use std::convert::Infallible; 9 + use std::sync::Arc; 10 + 11 + use crate::{key::KeyProvider, storage::DidDocumentStorage}; 12 + 13 + /// Axum request extractor for DID document storage. 14 + /// 15 + /// Automatically extracts a DID document storage implementation from the application state. 16 + #[derive(Clone)] 17 + pub struct DidDocumentStorageExtractor(pub Arc<dyn DidDocumentStorage + Send + Sync>); 18 + 19 + impl<S> FromRequestParts<S> for DidDocumentStorageExtractor 20 + where 21 + Arc<dyn DidDocumentStorage + Send + Sync>: FromRef<S>, 22 + S: Send + Sync, 23 + { 24 + type Rejection = Infallible; 25 + 26 + async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 27 + let storage = Arc::<dyn DidDocumentStorage + Send + Sync>::from_ref(state); 28 + Ok(DidDocumentStorageExtractor(storage)) 29 + } 30 + } 31 + 32 + /// Axum request extractor for key provider services. 33 + /// 34 + /// Automatically extracts a key provider implementation from the application state. 35 + #[derive(Clone)] 36 + pub struct KeyProviderExtractor(pub Arc<dyn KeyProvider + Send + Sync>); 37 + 38 + impl<S> FromRequestParts<S> for KeyProviderExtractor 39 + where 40 + Arc<dyn KeyProvider + Send + Sync>: FromRef<S>, 41 + S: Send + Sync, 42 + { 43 + type Rejection = Infallible; 44 + 45 + async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 46 + let key_provider = Arc::<dyn KeyProvider + Send + Sync>::from_ref(state); 47 + Ok(KeyProviderExtractor(key_provider)) 48 + } 49 + }
+32 -3
crates/atproto-identity/src/errors.rs
··· 10 10 //! - **`ConfigError`** (config-1 to config-3): Configuration and environment variable related errors 11 11 //! - **`ResolveError`** (resolve-1 to resolve-7): Handle and DID resolution errors including DNS/HTTP failures and conflicts 12 12 //! - **`PLCDIDError`** (plc-1 to plc-2): PLC directory communication and document parsing errors 13 - //! - **`KeyError`** (key-1 to key-9): Cryptographic key operations including generation, parsing, signing, and validation 13 + //! - **`KeyError`** (key-1 to key-13): Cryptographic key operations including generation, parsing, signing, and validation 14 14 //! - **`StorageError`** (storage-1 to storage-3): Storage operations including cache lock failures and data access errors 15 15 //! 16 16 //! ## Error Format ··· 75 75 #[derive(Debug, Error)] 76 76 pub enum ResolveError { 77 77 /// Occurs when multiple different DIDs are found via DNS TXT record lookup 78 - #[error("error-atproto-identity-resolve-1 Multiple DIDs resolved for handle: expected single DID")] 78 + #[error( 79 + "error-atproto-identity-resolve-1 Multiple DIDs resolved for handle: expected single DID" 80 + )] 79 81 MultipleDIDsFound, 80 82 81 83 /// Occurs when no DIDs are found via either DNS or HTTP resolution methods ··· 101 103 }, 102 104 103 105 /// Occurs when HTTP response from .well-known/atproto-did doesn't start with "did:" 104 - #[error("error-atproto-identity-resolve-6 Invalid HTTP resolution response: expected DID format")] 106 + #[error( 107 + "error-atproto-identity-resolve-6 Invalid HTTP resolution response: expected DID format" 108 + )] 105 109 InvalidHTTPResolutionResponse, 106 110 107 111 /// Occurs when input cannot be parsed as a valid handle or DID ··· 190 194 /// Occurs when attempting to generate a public key directly 191 195 #[error("error-atproto-identity-key-9 Public key generation not supported: generate private key instead")] 192 196 PublicKeyGenerationNotSupported, 197 + 198 + /// Occurs when the decoded key data is too short to identify the key type 199 + #[error("error-atproto-identity-key-10 Unidentified key type: key data too short")] 200 + UnidentifiedKeyType, 201 + 202 + /// Occurs when the multibase key type prefix is not recognized 203 + #[error("error-atproto-identity-key-11 Invalid multibase key type: {prefix:?}")] 204 + InvalidMultibaseKeyType { 205 + /// The unrecognized key type prefix 206 + prefix: Vec<u8>, 207 + }, 208 + 209 + /// Occurs when JWK format conversion is not supported for the key type 210 + #[error("error-atproto-identity-key-12 JWK format conversion not supported for key type: {key_type}")] 211 + JWKConversionNotSupported { 212 + /// The key type that doesn't support JWK conversion 213 + key_type: String, 214 + }, 215 + 216 + /// Occurs when JWK format conversion fails for supported key types 217 + #[error("error-atproto-identity-key-13 JWK format conversion failed: {error}")] 218 + JWKConversionFailed { 219 + /// The underlying conversion error 220 + error: String, 221 + }, 193 222 } 194 223 195 224 /// Error types that can occur when working with storage operations
+31 -18
crates/atproto-identity/src/key.rs
··· 50 50 //! } 51 51 //! ``` 52 52 53 - use anyhow::{anyhow, Result}; 53 + use anyhow::Result; 54 54 use ecdsa::signature::Signer; 55 55 use elliptic_curve::JwkEcKey; 56 56 ··· 135 135 } 136 136 } 137 137 138 + /// Trait for providing cryptographic keys by identifier. 139 + /// 140 + /// This trait defines the interface for key providers that can retrieve private keys 141 + /// by their identifier. Implementations must be thread-safe to support concurrent access. 142 + #[async_trait::async_trait] 143 + pub trait KeyProvider: Send + Sync { 144 + /// Retrieves a private key by its identifier. 145 + /// 146 + /// # Arguments 147 + /// * `key_id` - The identifier of the key to retrieve 148 + /// 149 + /// # Returns 150 + /// * `Ok(Some(KeyData))` - If the key was found and successfully retrieved 151 + /// * `Ok(None)` - If no key exists for the given identifier 152 + /// * `Err(anyhow::Error)` - If an error occurred during key retrieval 153 + async fn get_private_key_by_id(&self, key_id: &str) -> Result<Option<KeyData>>; 154 + } 155 + 138 156 /// DID key method prefix. 139 157 const DID_METHOD_KEY_PREFIX: &str = "did:key:"; 140 158 ··· 157 175 multibase::decode(stripped_key).map_err(|error| KeyError::DecodeError { error })?; 158 176 159 177 if decoded_multibase_key.len() < 3 { 160 - return Err(KeyError::InvalidKey { 161 - error: anyhow!("unidentified key type"), 162 - }); 178 + return Err(KeyError::UnidentifiedKeyType); 163 179 } 164 180 165 181 // These values were verified using the following method: ··· 198 214 decoded_multibase_key[2..].to_vec(), 199 215 )), 200 216 201 - _ => Err(KeyError::InvalidKey { 202 - error: anyhow!( 203 - "invalid multibase key type: {:?}", 204 - &decoded_multibase_key[..2] 205 - ), 217 + _ => Err(KeyError::InvalidMultibaseKeyType { 218 + prefix: decoded_multibase_key[..2].to_vec(), 206 219 }), 207 220 } 208 221 } ··· 289 302 match *self.key_type() { 290 303 KeyType::P256Public => { 291 304 let public_key = p256::PublicKey::from_sec1_bytes(self.bytes()).map_err(|e| { 292 - KeyError::InvalidKey { 293 - error: anyhow!("Failed to parse P256 public key: {}", e), 305 + KeyError::JWKConversionFailed { 306 + error: format!("Failed to parse P256 public key: {}", e), 294 307 } 295 308 })?; 296 309 Ok(public_key.to_jwk()) ··· 300 313 .map_err(|error| KeyError::SecretKeyError { error })?; 301 314 Ok(secret_key.to_jwk()) 302 315 } 303 - KeyType::K256Public => Err(KeyError::InvalidKey { 304 - error: anyhow!("K256 keys do not support JWK format conversion"), 316 + KeyType::K256Public => Err(KeyError::JWKConversionNotSupported { 317 + key_type: "K256Public".to_string(), 305 318 }), 306 - KeyType::K256Private => Err(KeyError::InvalidKey { 307 - error: anyhow!("K256 keys do not support JWK format conversion"), 319 + KeyType::K256Private => Err(KeyError::JWKConversionNotSupported { 320 + key_type: "K256Private".to_string(), 308 321 }), 309 322 } 310 323 } ··· 423 436 // Test invalid key type prefix 424 437 assert!(matches!( 425 438 identify_key("z4vLVqpQveB3w8G6MQsLVseJ1Z2E1JyQzUj6WgRYNNwB9jdE"), 426 - Err(KeyError::InvalidKey { .. }) 439 + Err(KeyError::InvalidMultibaseKeyType { .. }) 427 440 )); 428 441 } 429 442 ··· 537 550 assert!(private_jwk.is_err()); 538 551 assert!(matches!( 539 552 private_jwk.unwrap_err(), 540 - KeyError::InvalidKey { .. } 553 + KeyError::JWKConversionNotSupported { .. } 541 554 )); 542 555 543 556 let public_jwk: Result<elliptic_curve::JwkEcKey, _> = (&public_key_data).try_into(); 544 557 assert!(public_jwk.is_err()); 545 558 assert!(matches!( 546 559 public_jwk.unwrap_err(), 547 - KeyError::InvalidKey { .. } 560 + KeyError::JWKConversionNotSupported { .. } 548 561 )); 549 562 550 563 Ok(())
+3
crates/atproto-identity/src/lib.rs
··· 35 35 pub mod storage_lru; 36 36 pub mod validation; 37 37 pub mod web; 38 + 39 + #[cfg(feature = "axum")] 40 + pub mod axum;
+38
crates/atproto-oauth-axum/Cargo.toml
··· 1 + [package] 2 + name = "atproto-oauth-axum" 3 + version = "0.3.0" 4 + readme = "README.md" 5 + 6 + edition.workspace = true 7 + rust-version.workspace = true 8 + authors.workspace = true 9 + repository.workspace = true 10 + license.workspace = true 11 + keywords.workspace = true 12 + categories.workspace = true 13 + 14 + [dependencies] 15 + atproto-identity.workspace = true 16 + atproto-record.workspace = true 17 + atproto-oauth.workspace = true 18 + 19 + anyhow.workspace = true 20 + async-trait.workspace = true 21 + chrono.workspace = true 22 + elliptic-curve.workspace = true 23 + hickory-resolver.workspace = true 24 + rand.workspace = true 25 + reqwest-chain.workspace = true 26 + reqwest-middleware.workspace = true 27 + reqwest.workspace = true 28 + serde_json.workspace = true 29 + serde.workspace = true 30 + thiserror.workspace = true 31 + tokio.workspace = true 32 + tracing.workspace = true 33 + urlencoding = "2.1.3" 34 + axum = { version = "0.8", features = ["macros"] } 35 + http = "1.0.0" 36 + 37 + [lints] 38 + workspace = true
+350
crates/atproto-oauth-axum/README.md
··· 1 + # atproto-oauth-axum 2 + 3 + A Rust library providing complete Axum web handlers for AT Protocol OAuth 2.0 authorization server endpoints, including client metadata, JWKS, authorization callback handling, and a comprehensive command-line OAuth login tool. 4 + 5 + ## Overview 6 + 7 + `atproto-oauth-axum` provides ready-to-use Axum web handlers that implement the complete AT Protocol OAuth 2.0 authorization server specification. This library handles OAuth client metadata discovery, JSON Web Key Set (JWKS) endpoints, authorization callback processing, and includes a full-featured command-line tool for OAuth login flows. 8 + 9 + This project was extracted from the open-sourced [Smokesignal](https://tangled.sh/@smokesignal.events/smokesignal) project and is designed to be a standalone, reusable library for AT Protocol OAuth server implementations. 10 + 11 + ## Features 12 + 13 + - **Complete OAuth Server Handlers**: Ready-to-use Axum handlers for all required OAuth 2.0 endpoints 14 + - **Client Metadata Endpoint**: RFC 7591 compliant client metadata for dynamic client registration 15 + - **JWKS Endpoint**: JSON Web Key Set serving for JWT signature verification 16 + - **Authorization Callback Handler**: Complete OAuth callback processing with token exchange 17 + - **OAuth Login CLI Tool**: Full-featured command-line tool for testing and development OAuth flows 18 + - **Axum Integration**: Native Axum state management and request extractors 19 + - **Error Handling**: Comprehensive structured error types with proper HTTP responses 20 + - **AT Protocol Compliance**: Implements all AT Protocol-specific OAuth requirements 21 + 22 + ## Installation 23 + 24 + Add this to your `Cargo.toml`: 25 + 26 + ```toml 27 + [dependencies] 28 + atproto-oauth-axum = "0.3.0" 29 + ``` 30 + 31 + ## Usage 32 + 33 + ### Basic Axum Server Setup 34 + 35 + ```rust 36 + use atproto_oauth_axum::{ 37 + handle_complete::handle_oauth_callback, 38 + handle_jwks::handle_oauth_jwks, 39 + handler_metadata::handle_oauth_metadata, 40 + state::OAuthClientConfig, 41 + }; 42 + use axum::{routing::get, Router}; 43 + use atproto_identity::key::identify_key; 44 + 45 + #[tokio::main] 46 + async fn main() -> anyhow::Result<()> { 47 + // Set up OAuth client configuration 48 + let oauth_config = OAuthClientConfig { 49 + client_uri: "https://your-app.com".to_string(), 50 + client_id: "https://your-app.com/oauth/client-metadata.json".to_string(), 51 + redirect_uris: "https://your-app.com/oauth/callback".to_string(), 52 + jwks_uri: "https://your-app.com/.well-known/jwks.json".to_string(), 53 + signing_keys: vec![ 54 + identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")? 55 + ], 56 + }; 57 + 58 + // Create Axum router with OAuth handlers 59 + let app = Router::new() 60 + .route("/oauth/client-metadata.json", get(handle_oauth_metadata)) 61 + .route("/.well-known/jwks.json", get(handle_oauth_jwks)) 62 + .route("/oauth/callback", get(handle_oauth_callback)) 63 + .with_state(oauth_config); 64 + 65 + // Start the server 66 + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?; 67 + axum::serve(listener, app).await?; 68 + 69 + Ok(()) 70 + } 71 + ``` 72 + 73 + ### OAuth Client Metadata Handler 74 + 75 + ```rust 76 + use atproto_oauth_axum::{handler_metadata::handle_oauth_metadata, state::OAuthClientConfig}; 77 + use axum::{routing::get, Router}; 78 + 79 + // The metadata handler automatically generates RFC 7591 compliant client metadata 80 + let app = Router::new() 81 + .route("/oauth/client-metadata.json", get(handle_oauth_metadata)) 82 + .with_state(oauth_config); 83 + 84 + // Returns JSON like: 85 + // { 86 + // "client_id": "https://your-app.com/oauth/client-metadata.json", 87 + // "client_uri": "https://your-app.com", 88 + // "dpop_bound_access_tokens": true, 89 + // "application_type": "web", 90 + // "redirect_uris": ["https://your-app.com/oauth/callback"], 91 + // "grant_types": ["authorization_code", "refresh_token"], 92 + // "response_types": ["code"], 93 + // "scope": "atproto transition:generic", 94 + // "token_endpoint_auth_method": "private_key_jwt", 95 + // "jwks_uri": "https://your-app.com/.well-known/jwks.json" 96 + // } 97 + ``` 98 + 99 + ### JWKS Endpoint Handler 100 + 101 + ```rust 102 + use atproto_oauth_axum::{handle_jwks::handle_oauth_jwks, state::OAuthClientConfig}; 103 + use axum::{routing::get, Router}; 104 + 105 + // The JWKS handler automatically converts your signing keys to JWK format 106 + let app = Router::new() 107 + .route("/.well-known/jwks.json", get(handle_oauth_jwks)) 108 + .with_state(oauth_config); 109 + 110 + // Returns JSON Web Key Set with your public keys for signature verification 111 + ``` 112 + 113 + ### OAuth Callback Handler 114 + 115 + ```rust 116 + use atproto_oauth_axum::{ 117 + handle_complete::handle_oauth_callback, 118 + state::{OAuthClientConfig, HttpClient}, 119 + }; 120 + use atproto_identity::axum::state::{DidDocumentStorageExtractor, KeyProviderExtractor}; 121 + use atproto_oauth::axum::state::OAuthRequestStorageExtractor; 122 + use axum::{routing::get, Router}; 123 + 124 + // The callback handler processes OAuth authorization callbacks 125 + // It automatically: 126 + // - Validates OAuth state parameters 127 + // - Exchanges authorization codes for tokens 128 + // - Validates DPoP proofs 129 + // - Returns complete OAuth response with tokens 130 + 131 + let app = Router::new() 132 + .route("/oauth/callback", get(handle_oauth_callback)) 133 + .with_state(web_context); // Includes all required state 134 + ``` 135 + 136 + ### Integration with Other Libraries 137 + 138 + ```rust 139 + use atproto_oauth_axum::state::{OAuthClientConfig, HttpClient}; 140 + use atproto_identity::{ 141 + axum::state::{DidDocumentStorageExtractor, KeyProviderExtractor}, 142 + storage_lru::LruDidDocumentStorage, 143 + key::KeyProvider, 144 + }; 145 + use atproto_oauth::{ 146 + axum::state::OAuthRequestStorageExtractor, 147 + storage_lru::LruOAuthRequestStorage, 148 + }; 149 + use std::{num::NonZeroUsize, sync::Arc}; 150 + 151 + // Set up storage and state for full OAuth server 152 + let did_storage = Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(256).unwrap())); 153 + let oauth_storage = Arc::new(LruOAuthRequestStorage::new(NonZeroUsize::new(256).unwrap())); 154 + let key_provider = Arc::new(your_key_provider_impl); 155 + 156 + // The handlers automatically extract these from your application state 157 + ``` 158 + 159 + ## Command Line Tools 160 + 161 + The crate includes a comprehensive command-line tool for OAuth operations: 162 + 163 + ### `atproto-oauth-login` 164 + 165 + A complete OAuth login CLI tool that implements the full AT Protocol OAuth client flow. This tool sets up a local web server to handle OAuth callbacks and guides users through the complete authorization process from subject resolution to token acquisition. 166 + 167 + **Features:** 168 + - **Subject Resolution**: Automatically resolves AT Protocol handles or DIDs to their identity documents 169 + - **DID Document Retrieval**: Fetches and validates DID documents from PLC directory or Web DID endpoints 170 + - **PDS Discovery**: Discovers Personal Data Server (PDS) endpoints from DID documents 171 + - **Authorization Server Discovery**: Retrieves OAuth authorization server metadata from PDS resources 172 + - **PKCE Implementation**: Generates secure PKCE parameters for authorization code flows 173 + - **DPoP Key Generation**: Creates DPoP keys for bound access tokens 174 + - **Local OAuth Server**: Runs temporary web server to handle authorization callbacks 175 + - **Complete Token Exchange**: Handles full OAuth flow from authorization to token acquisition 176 + 177 + ```bash 178 + # Start OAuth login flow for a handle 179 + cargo run --bin atproto-oauth-login login did:key:zQ3sh... alice.bsky.social 180 + 181 + # Start OAuth login flow for a DID 182 + cargo run --bin atproto-oauth-login login did:key:zQ3sh... did:plc:user123 183 + 184 + # The tool will: 185 + # 1. Resolve the subject to a DID 186 + # 2. Fetch the DID document 187 + # 3. Discover the PDS endpoint 188 + # 4. Get OAuth authorization server configuration 189 + # 5. Generate PKCE and DPoP parameters 190 + # 6. Start a local server on http://localhost:8080 191 + # 7. Display the authorization URL to visit 192 + # 8. Handle the OAuth callback 193 + # 9. Exchange authorization code for tokens 194 + # 10. Display the complete OAuth response including access tokens and DPoP key 195 + 196 + # Example output: 197 + # OAuth server started on http://0.0.0.0:8080 198 + # 🔐 OAuth Authorization URL: 199 + # https://auth.bsky.social/oauth/authorize?client_id=https://localhost:8080/oauth/client-metadata.json&request_uri=urn:ietf:params:oauth:request_uri:abc123 200 + # 201 + # Please visit this URL in your browser to complete the OAuth flow. 202 + # The callback will be handled at: https://localhost:8080/oauth/callback 203 + ``` 204 + 205 + **Server Endpoints:** 206 + The tool automatically sets up these endpoints during the OAuth flow: 207 + - `GET /oauth/client-metadata.json` - OAuth client metadata 208 + - `GET /.well-known/jwks.json` - JSON Web Key Set 209 + - `GET /oauth/callback` - Authorization callback handler 210 + 211 + **Environment Variables:** 212 + ```bash 213 + # Required: Your application's external base URL 214 + export EXTERNAL_BASE=your-app.com 215 + 216 + # Optional: Custom PLC directory 217 + export PLC_HOSTNAME=plc.directory 218 + 219 + # Optional: Custom DNS nameservers 220 + export DNS_NAMESERVERS=8.8.8.8;1.1.1.1 221 + 222 + # Optional: Custom CA certificates 223 + export CERTIFICATE_BUNDLES=/path/to/cert.pem 224 + 225 + # Optional: Custom User-Agent 226 + export USER_AGENT="my-oauth-client/1.0" 227 + ``` 228 + 229 + **Security Features:** 230 + - Cryptographically secure PKCE code verifier generation 231 + - DPoP proof-of-possession for bound access tokens 232 + - State parameter validation for CSRF protection 233 + - Automatic nonce handling for DPoP challenges 234 + - Private key security with no key storage 235 + 236 + ## Modules 237 + 238 + - **[`handle_complete`]** - OAuth authorization callback handler with token exchange 239 + - **[`handler_metadata`]** - OAuth client metadata endpoint (RFC 7591) 240 + - **[`handle_jwks`]** - JSON Web Key Set endpoint for signature verification 241 + - **[`handle_init`]** - OAuth authorization initiation (reserved for future use) 242 + - **[`state`]** - Axum state management and request extractors 243 + - **[`errors`]** - Structured error types for OAuth operations 244 + 245 + ## Error Handling 246 + 247 + The crate uses comprehensive structured error types: 248 + 249 + ```rust 250 + use atproto_oauth_axum::errors::{OAuthCallbackError, OAuthLoginError}; 251 + 252 + // OAuth callback handler errors 253 + match callback_result { 254 + Err(OAuthCallbackError::NoOAuthRequestFound) => { 255 + println!("OAuth state not found - possible CSRF attack"); 256 + }, 257 + Err(OAuthCallbackError::InvalidIssuer { expected, actual }) => { 258 + println!("Issuer mismatch: expected {}, got {}", expected, actual); 259 + }, 260 + Err(OAuthCallbackError::NoDIDDocumentFound) => { 261 + println!("DID document not found for OAuth request"); 262 + }, 263 + Ok(response) => println!("OAuth callback successful"), 264 + } 265 + 266 + // OAuth login CLI errors 267 + match login_result { 268 + Err(OAuthLoginError::SubjectResolutionFailed { error }) => { 269 + println!("Failed to resolve subject: {}", error); 270 + }, 271 + Err(OAuthLoginError::NoPDSEndpointFound) => { 272 + println!("No PDS endpoint found in DID document"); 273 + }, 274 + Err(OAuthLoginError::OAuthInitFailed { error }) => { 275 + println!("OAuth initialization failed: {}", error); 276 + }, 277 + Ok(()) => println!("OAuth login completed successfully"), 278 + } 279 + ``` 280 + 281 + ### Error Format 282 + 283 + All errors follow the standardized format: 284 + 285 + ``` 286 + error-atproto-oauth-axum-<domain>-<number> <message>: <details> 287 + ``` 288 + 289 + Example error codes: 290 + - `error-atproto-oauth-axum-callback-1` through `error-atproto-oauth-axum-callback-7` - OAuth callback errors 291 + - `error-atproto-oauth-axum-login-1` through `error-atproto-oauth-axum-login-11` - OAuth login CLI errors 292 + 293 + ## AT Protocol Compliance 294 + 295 + This library implements all AT Protocol OAuth requirements: 296 + 297 + ### OAuth Server Requirements 298 + - Support for `authorization_code` and `refresh_token` grant types 299 + - PKCE with `S256` code challenge method 300 + - DPoP bound access tokens 301 + - `private_key_jwt` token endpoint authentication 302 + - `ES256` signing algorithm support 303 + - Required OAuth scopes (`atproto`, `transition:generic`) 304 + 305 + ### Client Requirements 306 + - Dynamic client registration metadata 307 + - JWKS endpoint for public key discovery 308 + - Proper redirect URI validation 309 + - State parameter CSRF protection 310 + 311 + ## Dependencies 312 + 313 + This crate builds on: 314 + 315 + - [`atproto-identity`](../atproto-identity) - Identity resolution and cryptographic operations 316 + - [`atproto-oauth`](../atproto-oauth) - Core OAuth 2.0 operations and security extensions 317 + - `axum` - Web framework for HTTP handlers 318 + - `reqwest` - HTTP client for OAuth server communication 319 + - `tokio` - Async runtime for web server operations 320 + - `serde_json` - JSON serialization for OAuth responses 321 + - `chrono` - Date and time handling for OAuth flows 322 + - `anyhow` - Error handling utilities 323 + - `thiserror` - Structured error type derivation 324 + 325 + ## Development and Testing 326 + 327 + This crate is ideal for: 328 + 329 + - **OAuth Server Development**: Complete Axum handlers for AT Protocol OAuth servers 330 + - **OAuth Client Testing**: CLI tool for testing OAuth flows against AT Protocol services 331 + - **Integration Testing**: Ready-to-use handlers for OAuth endpoint testing 332 + - **Development Workflows**: Local OAuth server for development and debugging 333 + 334 + ## Contributing 335 + 336 + Contributions are welcome! Please ensure that: 337 + 338 + 1. All tests pass: `cargo test` 339 + 2. Code is properly formatted: `cargo fmt` 340 + 3. No linting issues: `cargo clippy` 341 + 4. New functionality includes appropriate tests and documentation 342 + 5. Error handling follows the project's structured error format 343 + 344 + ## License 345 + 346 + This project is licensed under the MIT License. See the LICENSE file for details. 347 + 348 + ## Acknowledgments 349 + 350 + This library was extracted from the [Smokesignal](https://tangled.sh/@smokesignal.events/smokesignal) project, an open-source event and RSVP management and discovery application.
+403
crates/atproto-oauth-axum/src/bin/atproto-oauth-login.rs
··· 1 + use anyhow::Result; 2 + use async_trait::async_trait; 3 + use atproto_identity::{ 4 + config::{default_env, optional_env, require_env, version, CertificateBundles, DnsNameservers}, 5 + key::{generate_key, identify_key, to_public, KeyData, KeyProvider, KeyType}, 6 + plc, 7 + resolve::{create_resolver, resolve_subject}, 8 + storage::DidDocumentStorage, 9 + storage_lru::LruDidDocumentStorage, 10 + web, 11 + }; 12 + use atproto_oauth::{ 13 + pkce, 14 + resources::pds_resources, 15 + storage::OAuthRequestStorage, 16 + storage_lru::LruOAuthRequestStorage, 17 + workflow::{oauth_init, OAuthClient, OAuthRequest, OAuthRequestState}, 18 + }; 19 + use atproto_oauth_axum::errors::OAuthLoginError; 20 + use atproto_oauth_axum::{handle_complete::handle_oauth_callback, handle_jwks::handle_oauth_jwks}; 21 + use atproto_oauth_axum::{handler_metadata::handle_oauth_metadata, state::OAuthClientConfig}; 22 + use axum::{extract::FromRef, routing::get, Router}; 23 + use chrono::{Duration, Utc}; 24 + use hickory_resolver::TokioResolver; 25 + use rand::distributions::{Alphanumeric, DistString}; 26 + use std::{collections::HashMap, env, num::NonZeroUsize, ops::Deref, sync::Arc}; 27 + 28 + #[derive(Clone)] 29 + pub struct SimpleKeyProvider { 30 + keys: HashMap<String, KeyData>, 31 + } 32 + 33 + impl Default for SimpleKeyProvider { 34 + fn default() -> Self { 35 + Self::new() 36 + } 37 + } 38 + 39 + impl SimpleKeyProvider { 40 + pub fn new() -> Self { 41 + Self { 42 + keys: HashMap::new(), 43 + } 44 + } 45 + } 46 + 47 + #[async_trait] 48 + impl KeyProvider for SimpleKeyProvider { 49 + async fn get_private_key_by_id(&self, key_id: &str) -> anyhow::Result<Option<KeyData>> { 50 + Ok(self.keys.get(key_id).cloned()) 51 + } 52 + } 53 + 54 + pub struct InnerWebContext { 55 + pub http_client: reqwest::Client, 56 + pub dns_resolver: TokioResolver, 57 + pub oauth_client_config: OAuthClientConfig, 58 + pub oauth_storage: Arc<dyn OAuthRequestStorage + Send + Sync>, 59 + pub document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 60 + pub key_provider: Arc<dyn KeyProvider + Send + Sync>, 61 + } 62 + 63 + #[derive(Clone, FromRef)] 64 + pub struct WebContext(pub Arc<InnerWebContext>); 65 + 66 + impl Deref for WebContext { 67 + type Target = InnerWebContext; 68 + 69 + fn deref(&self) -> &Self::Target { 70 + &self.0 71 + } 72 + } 73 + 74 + impl FromRef<WebContext> for OAuthClientConfig { 75 + fn from_ref(context: &WebContext) -> Self { 76 + context.0.oauth_client_config.clone() 77 + } 78 + } 79 + 80 + impl FromRef<WebContext> for reqwest::Client { 81 + fn from_ref(context: &WebContext) -> Self { 82 + context.0.http_client.clone() 83 + } 84 + } 85 + 86 + impl FromRef<WebContext> for Arc<dyn OAuthRequestStorage + Send + Sync> { 87 + fn from_ref(context: &WebContext) -> Self { 88 + context.0.oauth_storage.clone() 89 + } 90 + } 91 + 92 + impl FromRef<WebContext> for Arc<dyn DidDocumentStorage + Send + Sync> { 93 + fn from_ref(context: &WebContext) -> Self { 94 + context.0.document_storage.clone() 95 + } 96 + } 97 + 98 + impl FromRef<WebContext> for Arc<dyn KeyProvider + Send + Sync> { 99 + fn from_ref(context: &WebContext) -> Self { 100 + context.0.key_provider.clone() 101 + } 102 + } 103 + 104 + fn print_usage() { 105 + println!("AT Protocol OAuth Login Tool"); 106 + println!(); 107 + println!("Usage:"); 108 + println!(" atproto-oauth-login login <private_signing_key> <subject>"); 109 + println!(" atproto-oauth-login refresh <private_signing_key> <subject> <private_dpop_key> <refresh_token>"); 110 + println!(); 111 + println!("Commands:"); 112 + println!(" login Start OAuth login flow"); 113 + println!(" refresh Refresh OAuth tokens"); 114 + println!(); 115 + println!("Arguments:"); 116 + println!(" private_signing_key Private key for signing"); 117 + println!(" subject OAuth subject identifier"); 118 + println!(" private_dpop_key Private DPoP key (refresh only)"); 119 + println!(" refresh_token Refresh token (refresh only)"); 120 + } 121 + 122 + #[tokio::main] 123 + async fn main() -> Result<()> { 124 + // Parse command line arguments 125 + let args: Vec<String> = env::args().skip(1).collect(); 126 + 127 + if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { 128 + print_usage(); 129 + return Ok(()); 130 + } 131 + 132 + let (subcommand, private_signing_key, subject, private_dpop_key, refresh_token) = 133 + match args.len() { 134 + 3 if args[0] == "login" => ("login", args[1].clone(), args[2].clone(), None, None), 135 + 5 if args[0] == "refresh" => ( 136 + "refresh", 137 + args[1].clone(), 138 + args[2].clone(), 139 + Some(args[3].clone()), 140 + Some(args[4].clone()), 141 + ), 142 + _ => { 143 + eprintln!("Error: Invalid arguments"); 144 + print_usage(); 145 + std::process::exit(1); 146 + } 147 + }; 148 + 149 + // Log the selected command 150 + println!( 151 + "Starting OAuth {} flow for subject: {}", 152 + subcommand, subject 153 + ); 154 + if let Some(ref dpop_key) = private_dpop_key { 155 + println!("Using DPoP key: {}", dpop_key); 156 + } 157 + if let Some(ref token) = refresh_token { 158 + println!( 159 + "Using refresh token: {}...", 160 + &token[..std::cmp::min(8, token.len())] 161 + ); 162 + } 163 + 164 + let _plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 165 + let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; 166 + let default_user_agent = format!( 167 + "atproto-identity-rs ({}; +https://tangled.sh/@smokesignal.events/atproto-identity-rs)", 168 + version()? 169 + ); 170 + let user_agent = default_env("USER_AGENT", &default_user_agent); 171 + let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 172 + 173 + let mut client_builder = reqwest::Client::builder(); 174 + for ca_certificate in certificate_bundles.as_ref() { 175 + let cert = std::fs::read(ca_certificate)?; 176 + let cert = reqwest::Certificate::from_pem(&cert)?; 177 + client_builder = client_builder.add_root_certificate(cert); 178 + } 179 + 180 + client_builder = client_builder.user_agent(user_agent); 181 + let http_client = client_builder.build()?; 182 + 183 + let dns_resolver = create_resolver(dns_nameservers.as_ref()); 184 + 185 + let external_base = require_env("EXTERNAL_BASE")?; 186 + 187 + // Load signing keys for JWK generation 188 + let mut signing_keys = Vec::new(); 189 + if subcommand == "login" { 190 + // For login command, include the provided signing key in the JWKS 191 + match identify_key(&private_signing_key) { 192 + Ok(key_data) => { 193 + signing_keys.push(key_data); 194 + } 195 + Err(e) => tracing::warn!(error = ?e, "Failed to parse signing key for JWKS"), 196 + } 197 + } 198 + 199 + let oauth_client_config = OAuthClientConfig { 200 + client_uri: format!("https://{}", &external_base), 201 + jwks_uri: format!("https://{}/.well-known/jwks.json", &external_base), 202 + redirect_uris: format!("https://{}/oauth/callback", &external_base), 203 + client_id: format!("https://{}/oauth/client-metadata.json", &external_base), 204 + signing_keys: signing_keys 205 + .iter() 206 + .filter_map(|value| to_public(value).ok()) 207 + .collect(), 208 + }; 209 + 210 + let mut signing_key_storage = HashMap::new(); 211 + 212 + for signing_key in &signing_keys { 213 + let public_signing_key_data = to_public(signing_key)?; 214 + 215 + let public_signing_key = public_signing_key_data.to_string(); 216 + signing_key_storage.insert(public_signing_key, signing_key.clone()); 217 + } 218 + 219 + let web_context = WebContext(Arc::new(InnerWebContext { 220 + http_client: http_client.clone(), 221 + dns_resolver: dns_resolver.clone(), 222 + oauth_client_config: oauth_client_config.clone(), 223 + oauth_storage: Arc::new(LruOAuthRequestStorage::new(NonZeroUsize::new(256).unwrap())), 224 + document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())), 225 + key_provider: Arc::new(SimpleKeyProvider { 226 + keys: signing_key_storage, 227 + }), 228 + })); 229 + 230 + let router = Router::new() 231 + .route("/oauth/client-metadata.json", get(handle_oauth_metadata)) 232 + .route("/.well-known/jwks.json", get(handle_oauth_jwks)) 233 + .route("/oauth/callback", get(handle_oauth_callback)) 234 + .with_state(web_context.clone()); 235 + 236 + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?; 237 + 238 + // Start the web server in the background 239 + let server_handle = tokio::spawn(async move { 240 + if let Err(e) = axum::serve(listener, router).await { 241 + eprintln!("Server error: {}", e); 242 + } 243 + }); 244 + 245 + println!("OAuth server started on http://0.0.0.0:8080"); 246 + 247 + // Handle the login command 248 + if subcommand == "login" { 249 + handle_login_command( 250 + &http_client, 251 + &dns_resolver, 252 + &private_signing_key, 253 + &subject, 254 + &external_base, 255 + &web_context.document_storage, 256 + &web_context.oauth_storage, 257 + ) 258 + .await?; 259 + 260 + println!("\nServer is running to handle the OAuth callback..."); 261 + println!("Press Ctrl+C to stop the server when done."); 262 + } 263 + 264 + // Keep the server running 265 + server_handle.await.unwrap(); 266 + 267 + Ok(()) 268 + } 269 + 270 + async fn handle_login_command( 271 + http_client: &reqwest::Client, 272 + dns_resolver: &TokioResolver, 273 + private_signing_key: &str, 274 + subject: &str, 275 + external_base: &str, 276 + did_document_storage: &Arc<dyn DidDocumentStorage + Send + Sync>, 277 + oauth_request_storage: &Arc<dyn OAuthRequestStorage + Send + Sync>, 278 + ) -> Result<()> { 279 + println!("Resolving subject: {}", subject); 280 + 281 + // Resolve the subject to a DID 282 + let did = resolve_subject(http_client, dns_resolver, subject) 283 + .await 284 + .map_err(|e| OAuthLoginError::SubjectResolutionFailed { error: e.into() })?; 285 + 286 + println!("Resolved DID: {}", did); 287 + 288 + // Get the DID document based on DID type 289 + let document = if did.starts_with("did:plc:") { 290 + plc::query(http_client, "plc.directory", &did) 291 + .await 292 + .map_err(|e| OAuthLoginError::PLCQueryFailed { error: e.into() })? 293 + } else if did.starts_with("did:web:") { 294 + web::query(http_client, &did) 295 + .await 296 + .map_err(|e| OAuthLoginError::WebDIDQueryFailed { error: e.into() })? 297 + } else { 298 + return Err(OAuthLoginError::UnsupportedDIDMethod { did }.into()); 299 + }; 300 + 301 + did_document_storage 302 + .store_document(document.clone()) 303 + .await?; 304 + 305 + println!("Retrieved DID document for: {}", document.id); 306 + 307 + // Get PDS endpoint from the DID document 308 + let pds_endpoints = document.pds_endpoints(); 309 + let pds_endpoint = pds_endpoints 310 + .first() 311 + .ok_or(OAuthLoginError::NoPDSEndpointFound)?; 312 + 313 + println!("Using PDS endpoint: {}", pds_endpoint); 314 + 315 + // Get authorization server configuration 316 + let (_, authorization_server) = pds_resources(http_client, pds_endpoint) 317 + .await 318 + .map_err(|e| OAuthLoginError::PDSResourcesFailed { error: e.into() })?; 319 + 320 + println!("Authorization server: {}", authorization_server.issuer); 321 + 322 + // Generate OAuth security parameters 323 + let (pkce_verifier, code_challenge) = pkce::generate(); 324 + let state = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); 325 + let nonce = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); 326 + 327 + // Generate DPoP key 328 + let dpop_key = generate_key(KeyType::P256Private) 329 + .map_err(|e| OAuthLoginError::DPoPKeyGenerationFailed { error: e.into() })?; 330 + 331 + // Parse the private signing key 332 + let signing_key = identify_key(private_signing_key) 333 + .map_err(|e| OAuthLoginError::InvalidPrivateSigningKey { error: e.into() })?; 334 + 335 + // Create OAuth client configuration 336 + let oauth_client = OAuthClient { 337 + redirect_uri: format!("https://{}/oauth/callback", external_base), 338 + client_id: format!("https://{}/oauth/client-metadata.json", external_base), 339 + private_signing_key_data: signing_key.clone(), 340 + }; 341 + 342 + // Create OAuth request state 343 + let oauth_request_state = OAuthRequestState { 344 + state: state.clone(), 345 + nonce: nonce.clone(), 346 + code_challenge, 347 + }; 348 + 349 + println!("Initiating OAuth flow..."); 350 + 351 + // Initiate OAuth flow 352 + let par_response = oauth_init( 353 + http_client, 354 + &oauth_client, 355 + &dpop_key, 356 + subject, 357 + &authorization_server, 358 + &oauth_request_state, 359 + ) 360 + .await 361 + .map_err(|e| OAuthLoginError::OAuthInitFailed { error: e.into() })?; 362 + 363 + // Store OAuth request state for callback handling 364 + let public_signing_key = to_public(&signing_key) 365 + .map_err(|e| OAuthLoginError::PublicKeyDerivationFailed { error: e.into() })?; 366 + 367 + let now = Utc::now(); 368 + let oauth_request = OAuthRequest { 369 + oauth_state: state.clone(), 370 + issuer: authorization_server.issuer.clone(), 371 + did: did.clone(), 372 + nonce: nonce.clone(), 373 + pkce_verifier, 374 + signing_public_key: public_signing_key.to_string(), 375 + dpop_private_key: dpop_key.to_string(), 376 + created_at: now, 377 + expires_at: now + Duration::hours(1), 378 + }; 379 + 380 + oauth_request_storage 381 + .insert_oauth_request(oauth_request) 382 + .await 383 + .map_err(|e| OAuthLoginError::OAuthRequestStorageFailed { error: e })?; 384 + 385 + // Compose the authorization URL 386 + let auth_url = format!( 387 + "{}?client_id={}&request_uri={}", 388 + authorization_server.authorization_endpoint, 389 + oauth_client.client_id, 390 + par_response.request_uri 391 + ); 392 + 393 + println!("\n🔐 OAuth Authorization URL:"); 394 + println!("{}\n", auth_url); 395 + println!("Please visit this URL in your browser to complete the OAuth flow."); 396 + println!( 397 + "The callback will be handled at: https://{}/oauth/callback", 398 + external_base 399 + ); 400 + println!("OAuth state: {}", state); 401 + 402 + Ok(()) 403 + }
+166
crates/atproto-oauth-axum/src/errors.rs
··· 1 + //! # Structured Error Types for OAuth Axum Handlers 2 + //! 3 + //! Comprehensive error handling for AT Protocol OAuth Axum web handlers using structured error types 4 + //! with the `thiserror` library. All errors follow the project convention of prefixed error codes 5 + //! with descriptive messages. 6 + //! 7 + //! ## Error Categories 8 + //! 9 + //! - **`OAuthCallbackError`** (callback-1 to callback-7): OAuth callback handler errors 10 + //! - **`OAuthLoginError`** (login-1 to login-11): OAuth login CLI tool errors 11 + //! 12 + //! ## Error Format 13 + //! 14 + //! All errors use the standardized format: `error-atproto-oauth-axum-{domain}-{number} {message}: {details}` 15 + 16 + use thiserror::Error; 17 + 18 + /// Error types that can occur during OAuth callback handling. 19 + /// 20 + /// These errors represent failures in the OAuth authorization callback flow 21 + /// including request validation and token exchange operations. 22 + #[derive(Debug, Error)] 23 + pub enum OAuthCallbackError { 24 + /// Occurs when no OAuth request is found for the provided state parameter 25 + #[error("error-atproto-oauth-axum-callback-1 No OAuth request found for state")] 26 + NoOAuthRequestFound, 27 + 28 + /// Occurs when the issuer in the callback doesn't match the stored OAuth request 29 + #[error( 30 + "error-atproto-oauth-axum-callback-2 Invalid issuer: expected {expected}, got {actual}" 31 + )] 32 + InvalidIssuer { 33 + /// The expected issuer from the stored OAuth request 34 + expected: String, 35 + /// The actual issuer from the callback 36 + actual: String, 37 + }, 38 + 39 + /// Occurs when no DID document is found for the OAuth request 40 + #[error("error-atproto-oauth-axum-callback-3 No DID document found for OAuth request")] 41 + NoDIDDocumentFound, 42 + 43 + /// Occurs when no signing key is found for the OAuth request 44 + #[error("error-atproto-oauth-axum-callback-4 No signing key found for OAuth request")] 45 + NoSigningKeyFound, 46 + 47 + /// Occurs when an underlying operation fails with an anyhow error 48 + #[error("error-atproto-oauth-axum-callback-5 Operation failed: {error}")] 49 + OperationFailed { 50 + /// The underlying anyhow error 51 + error: anyhow::Error, 52 + }, 53 + 54 + /// Occurs when key operations fail 55 + #[error("error-atproto-oauth-axum-callback-6 Key operation failed: {error}")] 56 + KeyOperationFailed { 57 + /// The underlying key error 58 + error: atproto_identity::errors::KeyError, 59 + }, 60 + 61 + /// Occurs when OAuth client operations fail 62 + #[error("error-atproto-oauth-axum-callback-7 OAuth client operation failed: {error}")] 63 + OAuthClientOperationFailed { 64 + /// The underlying OAuth client error 65 + error: atproto_oauth::errors::OAuthClientError, 66 + }, 67 + } 68 + 69 + impl From<anyhow::Error> for OAuthCallbackError { 70 + fn from(error: anyhow::Error) -> Self { 71 + OAuthCallbackError::OperationFailed { error } 72 + } 73 + } 74 + 75 + impl From<atproto_identity::errors::KeyError> for OAuthCallbackError { 76 + fn from(error: atproto_identity::errors::KeyError) -> Self { 77 + OAuthCallbackError::KeyOperationFailed { error } 78 + } 79 + } 80 + 81 + impl From<atproto_oauth::errors::OAuthClientError> for OAuthCallbackError { 82 + fn from(error: atproto_oauth::errors::OAuthClientError) -> Self { 83 + OAuthCallbackError::OAuthClientOperationFailed { error } 84 + } 85 + } 86 + 87 + /// Error types that can occur during OAuth login CLI operations. 88 + /// 89 + /// These errors represent failures in the OAuth login command-line tool 90 + /// including subject resolution, DID operations, and OAuth flow initiation. 91 + #[derive(Debug, Error)] 92 + pub enum OAuthLoginError { 93 + /// Occurs when subject resolution fails 94 + #[error("error-atproto-oauth-axum-login-1 Failed to resolve subject: {error}")] 95 + SubjectResolutionFailed { 96 + /// The underlying resolution error 97 + error: anyhow::Error, 98 + }, 99 + 100 + /// Occurs when PLC directory query fails 101 + #[error("error-atproto-oauth-axum-login-2 Failed to query PLC directory: {error}")] 102 + PLCQueryFailed { 103 + /// The underlying PLC error 104 + error: anyhow::Error, 105 + }, 106 + 107 + /// Occurs when web DID query fails 108 + #[error("error-atproto-oauth-axum-login-3 Failed to query web DID: {error}")] 109 + WebDIDQueryFailed { 110 + /// The underlying web DID error 111 + error: anyhow::Error, 112 + }, 113 + 114 + /// Occurs when an unsupported DID method is encountered 115 + #[error("error-atproto-oauth-axum-login-4 Unsupported DID method: {did}")] 116 + UnsupportedDIDMethod { 117 + /// The unsupported DID identifier 118 + did: String, 119 + }, 120 + 121 + /// Occurs when no PDS endpoint is found in the DID document 122 + #[error("error-atproto-oauth-axum-login-5 No PDS endpoint found in DID document")] 123 + NoPDSEndpointFound, 124 + 125 + /// Occurs when PDS resources retrieval fails 126 + #[error("error-atproto-oauth-axum-login-6 Failed to get PDS resources: {error}")] 127 + PDSResourcesFailed { 128 + /// The underlying PDS resources error 129 + error: anyhow::Error, 130 + }, 131 + 132 + /// Occurs when DPoP key generation fails 133 + #[error("error-atproto-oauth-axum-login-7 Failed to generate DPoP key: {error}")] 134 + DPoPKeyGenerationFailed { 135 + /// The underlying key generation error 136 + error: anyhow::Error, 137 + }, 138 + 139 + /// Occurs when private signing key parsing fails 140 + #[error("error-atproto-oauth-axum-login-8 Invalid private signing key: {error}")] 141 + InvalidPrivateSigningKey { 142 + /// The underlying key parsing error 143 + error: anyhow::Error, 144 + }, 145 + 146 + /// Occurs when OAuth initialization fails 147 + #[error("error-atproto-oauth-axum-login-9 OAuth init failed: {error}")] 148 + OAuthInitFailed { 149 + /// The underlying OAuth initialization error 150 + error: anyhow::Error, 151 + }, 152 + 153 + /// Occurs when public key derivation fails 154 + #[error("error-atproto-oauth-axum-login-10 Failed to derive public key: {error}")] 155 + PublicKeyDerivationFailed { 156 + /// The underlying key derivation error 157 + error: anyhow::Error, 158 + }, 159 + 160 + /// Occurs when OAuth request storage fails 161 + #[error("error-atproto-oauth-axum-login-11 Failed to store OAuth request: {error}")] 162 + OAuthRequestStorageFailed { 163 + /// The underlying storage error 164 + error: anyhow::Error, 165 + }, 166 + }
+140
crates/atproto-oauth-axum/src/handle_complete.rs
··· 1 + //! OAuth authorization callback handler. 2 + //! 3 + //! Handles the OAuth authorization callback by exchanging authorization codes for tokens, 4 + //! validating OAuth state, and completing the OAuth flow with proper error handling. 5 + 6 + use anyhow::Result; 7 + use atproto_identity::{ 8 + axum::state::{DidDocumentStorageExtractor, KeyProviderExtractor}, 9 + key::identify_key, 10 + }; 11 + use atproto_oauth::{ 12 + axum::state::OAuthRequestStorageExtractor, 13 + workflow::{oauth_complete, OAuthClient}, 14 + }; 15 + use axum::{ 16 + response::{IntoResponse, Response}, 17 + Form, 18 + }; 19 + use http::StatusCode; 20 + use serde::{Deserialize, Serialize}; 21 + 22 + use crate::{ 23 + errors::OAuthCallbackError, 24 + state::{HttpClient, OAuthClientConfig}, 25 + }; 26 + 27 + /// OAuth authorization callback form data. 28 + /// 29 + /// Contains the parameters sent by the authorization server during the OAuth callback. 30 + #[derive(Deserialize, Serialize)] 31 + pub struct OAuthCallbackForm { 32 + /// OAuth state parameter for CSRF protection 33 + pub state: String, 34 + /// Authorization server issuer identifier 35 + pub iss: String, 36 + /// Authorization code from the authorization server 37 + pub code: String, 38 + } 39 + 40 + impl IntoResponse for OAuthCallbackError { 41 + fn into_response(self) -> Response { 42 + tracing::error!(error = ?self, "OAuth callback error"); 43 + ( 44 + StatusCode::INTERNAL_SERVER_ERROR, 45 + format!("OAuth callback failed: {}", self), 46 + ) 47 + .into_response() 48 + } 49 + } 50 + 51 + /// Handles OAuth authorization callback requests. 52 + /// 53 + /// Processes the authorization callback by validating the OAuth state, exchanging 54 + /// the authorization code for tokens, and returning the complete OAuth response. 55 + pub async fn handle_oauth_callback( 56 + oauth_client_config: OAuthClientConfig, 57 + client: HttpClient, 58 + oauth_request_storage: OAuthRequestStorageExtractor, 59 + did_document_storage: DidDocumentStorageExtractor, 60 + key_provider: KeyProviderExtractor, 61 + Form(callback_form): Form<OAuthCallbackForm>, 62 + ) -> Result<impl IntoResponse, OAuthCallbackError> { 63 + let oauth_request = oauth_request_storage 64 + .0 65 + .get_oauth_request_by_state(&callback_form.state) 66 + .await?; 67 + 68 + let oauth_request = oauth_request.ok_or(OAuthCallbackError::NoOAuthRequestFound)?; 69 + 70 + if oauth_request.issuer != callback_form.iss { 71 + return Err(OAuthCallbackError::InvalidIssuer { 72 + expected: oauth_request.issuer.clone(), 73 + actual: callback_form.iss.clone(), 74 + }); 75 + } 76 + 77 + let document = did_document_storage 78 + .0 79 + .get_document_by_did(&oauth_request.did) 80 + .await?; 81 + 82 + let document = document.ok_or(OAuthCallbackError::NoDIDDocumentFound)?; 83 + 84 + let private_signing_key_data = key_provider 85 + .0 86 + .get_private_key_by_id(&oauth_request.signing_public_key) 87 + .await?; 88 + 89 + let private_signing_key_data = 90 + private_signing_key_data.ok_or(OAuthCallbackError::NoSigningKeyFound)?; 91 + 92 + let private_dpop_key_data = identify_key(&oauth_request.dpop_private_key)?; 93 + 94 + let oauth_client = OAuthClient { 95 + redirect_uri: oauth_client_config.redirect_uris, 96 + client_id: oauth_client_config.client_id, 97 + private_signing_key_data, 98 + }; 99 + 100 + let token_response = oauth_complete( 101 + &client, 102 + &oauth_client, 103 + &private_dpop_key_data, 104 + &callback_form.code, 105 + &oauth_request, 106 + &document, 107 + ) 108 + .await?; 109 + 110 + // Format the response with OAuth tokens and DPoP key information 111 + let response_body = format!( 112 + "OAuth Callback Completed Successfully\n\ 113 + =====================================\n\ 114 + DID: {}\n\ 115 + Issuer: {}\n\ 116 + Access Token: {}\n\ 117 + Refresh Token: {}\n\ 118 + Token Type: {}\n\ 119 + Expires In: {} seconds\n\ 120 + Scope: {}\n\ 121 + Subject: {}\n\ 122 + Private DPoP Key: {}\n", 123 + oauth_request.did, 124 + oauth_request.issuer, 125 + token_response.access_token, 126 + token_response.refresh_token, 127 + token_response.token_type, 128 + token_response.expires_in, 129 + token_response.scope, 130 + token_response.sub, 131 + private_dpop_key_data 132 + ); 133 + 134 + Ok(( 135 + StatusCode::OK, 136 + [("Content-Type", "text/plain")], 137 + response_body, 138 + ) 139 + .into_response()) 140 + }
+4
crates/atproto-oauth-axum/src/handle_init.rs
··· 1 + //! OAuth authorization initiation handler (placeholder). 2 + //! 3 + //! Reserved module for OAuth authorization initiation endpoints. 4 + //! Currently unused but available for future implementation.
+32
crates/atproto-oauth-axum/src/handle_jwks.rs
··· 1 + //! OAuth JSON Web Key Set (JWKS) endpoint handler. 2 + //! 3 + //! Serves the JWKS endpoint for OAuth client public keys, enabling authorization servers 4 + //! to verify JWT signatures from the OAuth client during authentication flows. 5 + 6 + use atproto_oauth::jwk::{generate, WrappedJsonWebKey}; 7 + use axum::{response::IntoResponse, Json}; 8 + use serde::Serialize; 9 + 10 + use crate::state::OAuthClientConfig; 11 + 12 + /// JSON Web Key Set response structure. 13 + /// 14 + /// Contains a collection of public keys for JWT signature verification. 15 + #[derive(Serialize)] 16 + pub struct WrappedJsonWebKeySet { 17 + /// Array of JSON Web Keys 18 + pub keys: Vec<WrappedJsonWebKey>, 19 + } 20 + 21 + /// Handles requests for the OAuth JWKS (JSON Web Key Set) endpoint. 22 + /// 23 + /// Returns the public keys used by this OAuth client for JWT signature verification. 24 + pub async fn handle_oauth_jwks(oauth_client_config: OAuthClientConfig) -> impl IntoResponse { 25 + let mut jwks = Vec::new(); 26 + for key_data in &oauth_client_config.signing_keys { 27 + if let Ok(jwk) = generate(key_data) { 28 + jwks.push(jwk); 29 + } 30 + } 31 + Json(WrappedJsonWebKeySet { keys: jwks }) 32 + }
+48
crates/atproto-oauth-axum/src/handler_metadata.rs
··· 1 + //! OAuth client metadata endpoint handler. 2 + //! 3 + //! Serves OAuth 2.0 client metadata according to RFC 7591, providing client configuration 4 + //! information required for dynamic client registration and authorization server discovery. 5 + 6 + use axum::{response::IntoResponse, Json}; 7 + use serde::Serialize; 8 + 9 + use crate::state::OAuthClientConfig; 10 + 11 + #[derive(Serialize)] 12 + struct AuthMetadata { 13 + client_id: String, 14 + dpop_bound_access_tokens: bool, 15 + application_type: &'static str, 16 + redirect_uris: Vec<String>, 17 + client_uri: String, 18 + grant_types: Vec<&'static str>, 19 + response_types: Vec<&'static str>, 20 + scope: &'static str, 21 + client_name: &'static str, 22 + token_endpoint_auth_method: &'static str, 23 + jwks_uri: String, 24 + subject_type: &'static str, 25 + token_endpoint_auth_signing_alg: &'static str, 26 + } 27 + 28 + /// Handles requests for OAuth client metadata. 29 + /// 30 + /// Returns RFC 7591 compliant client metadata for dynamic client registration. 31 + pub async fn handle_oauth_metadata(oauth_client_config: OAuthClientConfig) -> impl IntoResponse { 32 + let resp = AuthMetadata { 33 + application_type: "web", 34 + client_id: oauth_client_config.client_id.clone(), 35 + client_name: "Smoke Signal", 36 + client_uri: oauth_client_config.client_uri.clone(), 37 + dpop_bound_access_tokens: true, 38 + grant_types: vec!["authorization_code", "refresh_token"], 39 + jwks_uri: oauth_client_config.jwks_uri.clone(), 40 + redirect_uris: vec![oauth_client_config.redirect_uris.clone()], 41 + response_types: vec!["code"], 42 + scope: "atproto transition:generic", 43 + token_endpoint_auth_method: "private_key_jwt", 44 + token_endpoint_auth_signing_alg: "ES256", 45 + subject_type: "public", 46 + }; 47 + Json(resp) 48 + }
+13
crates/atproto-oauth-axum/src/lib.rs
··· 1 + //! AT Protocol OAuth Axum web handlers. 2 + //! 3 + //! Complete Axum web handlers for implementing AT Protocol OAuth 2.0 authorization server 4 + //! endpoints including client metadata, JWKS, and authorization callback handling. 5 + 6 + #![warn(missing_docs)] 7 + 8 + pub mod errors; 9 + pub mod handle_complete; 10 + pub mod handle_init; 11 + pub mod handle_jwks; 12 + pub mod handler_metadata; 13 + pub mod state;
+66
crates/atproto-oauth-axum/src/state.rs
··· 1 + //! Axum state management for OAuth client configuration. 2 + //! 3 + //! Provides request extractors and HTTP client wrappers for injecting OAuth client 4 + //! configuration and HTTP clients into Axum request handlers. 5 + 6 + use atproto_identity::key::KeyData; 7 + use axum::extract::{FromRef, FromRequestParts}; 8 + use http::request::Parts; 9 + use std::convert::Infallible; 10 + 11 + /// OAuth client configuration for Axum handlers. 12 + /// 13 + /// Contains the essential configuration needed for OAuth client operations. 14 + #[derive(Clone)] 15 + pub struct OAuthClientConfig { 16 + /// OAuth client URI 17 + pub client_uri: String, 18 + /// OAuth client identifier 19 + pub client_id: String, 20 + /// Allowed OAuth redirect URIs 21 + pub redirect_uris: String, 22 + /// JSON Web Key Set URI for public keys 23 + pub jwks_uri: String, 24 + /// Signing keys for JWT operations 25 + pub signing_keys: Vec<KeyData>, 26 + } 27 + 28 + impl<S> FromRequestParts<S> for OAuthClientConfig 29 + where 30 + OAuthClientConfig: FromRef<S>, 31 + S: Send + Sync, 32 + { 33 + type Rejection = Infallible; 34 + 35 + async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 36 + let oauth_client_config = OAuthClientConfig::from_ref(state); 37 + Ok(oauth_client_config) 38 + } 39 + } 40 + 41 + /// HTTP client wrapper for dependency injection. 42 + /// 43 + /// Wraps a reqwest::Client for use in Axum extractors. 44 + #[derive(Clone)] 45 + pub struct HttpClient(pub reqwest::Client); 46 + 47 + impl std::ops::Deref for HttpClient { 48 + type Target = reqwest::Client; 49 + 50 + fn deref(&self) -> &Self::Target { 51 + &self.0 52 + } 53 + } 54 + 55 + impl<S> FromRequestParts<S> for HttpClient 56 + where 57 + reqwest::Client: FromRef<S>, 58 + S: Send + Sync, 59 + { 60 + type Rejection = Infallible; 61 + 62 + async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 63 + let client = reqwest::Client::from_ref(state); 64 + Ok(HttpClient(client)) 65 + } 66 + }
+5
crates/atproto-oauth/Cargo.toml
··· 35 35 tracing.workspace = true 36 36 ulid.workspace = true 37 37 38 + axum = { version = "0.8", optional = true } 39 + http = { version = "1.0.0", optional = true } 40 + 38 41 [features] 42 + default = ["lru", "axum"] 39 43 lru = ["dep:lru"] 44 + axum = ["dep:axum", "dep:http"] 40 45 41 46 [lints] 42 47 workspace = true
+6
crates/atproto-oauth/src/axum/mod.rs
··· 1 + //! Axum web framework integration for AT Protocol OAuth operations. 2 + //! 3 + //! Provides request extractors and state management for integrating AT Protocol 4 + //! OAuth workflows with Axum web applications. 5 + 6 + pub mod state;
+29
crates/atproto-oauth/src/axum/state.rs
··· 1 + //! Axum request extractors for AT Protocol OAuth services. 2 + //! 3 + //! Provides extractors that automatically inject OAuth request storage 4 + //! into Axum request handlers from application state. 5 + 6 + use axum::extract::{FromRef, FromRequestParts}; 7 + use http::request::Parts; 8 + use std::{convert::Infallible, sync::Arc}; 9 + 10 + use crate::storage::OAuthRequestStorage; 11 + 12 + /// Axum request extractor for OAuth request storage. 13 + /// 14 + /// Automatically extracts an OAuth request storage implementation from the application state. 15 + #[derive(Clone)] 16 + pub struct OAuthRequestStorageExtractor(pub Arc<dyn OAuthRequestStorage + Send + Sync>); 17 + 18 + impl<S> FromRequestParts<S> for OAuthRequestStorageExtractor 19 + where 20 + Arc<dyn OAuthRequestStorage + Send + Sync>: FromRef<S>, 21 + S: Send + Sync, 22 + { 23 + type Rejection = Infallible; 24 + 25 + async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 26 + let storage = Arc::<dyn OAuthRequestStorage + Send + Sync>::from_ref(state); 27 + Ok(OAuthRequestStorageExtractor(storage)) 28 + } 29 + }
+3
crates/atproto-oauth/src/lib.rs
··· 25 25 pub mod storage_lru; 26 26 /// OAuth workflow implementation for AT Protocol authorization flows. 27 27 pub mod workflow; 28 + 29 + #[cfg(feature = "axum")] 30 + pub mod axum;
+54 -7
crates/atproto-record/README.md
··· 90 90 91 91 ## Command Line Tools 92 92 93 - The crate includes command-line tools for AT Protocol record signature operations: 93 + The crate includes two command-line tools for AT Protocol record signature operations: 94 94 95 95 ### `atproto-record-sign` 96 - Creates cryptographic signatures for AT Protocol records. Reads a JSON record from a file or stdin, applies a cryptographic signature using a DID key, and outputs the signed record with embedded signature metadata. Supports flexible argument ordering and uses IPLD DAG-CBOR serialization for consistent signature generation. 96 + 97 + Creates cryptographic signatures for AT Protocol records with proper `$sig` object handling and embedded signature metadata. This tool reads JSON records, applies cryptographic signatures using DID keys, and outputs signed records ready for AT Protocol repository storage. 98 + 99 + **Features:** 100 + - **Flexible Input**: Reads records from files or stdin 101 + - **DID Key Support**: Works with both P-256 and K-256 cryptographic keys 102 + - **Signature Object Creation**: Automatically creates required signature metadata with issuer and timestamp 103 + - **Repository Context**: Includes repository and collection context in signatures 104 + - **IPLD Serialization**: Uses DAG-CBOR serialization for consistent signature generation 105 + - **Multibase Encoding**: Outputs signatures in multibase format for AT Protocol compatibility 97 106 98 107 ```bash 99 - # Sign a record from a file 108 + # Sign a record from a file with all required parameters 100 109 cargo run --bin atproto-record-sign did:key:zQ3sh... did:plc:issuer123 record.json repository=did:plc:user123 collection=app.bsky.feed.post 101 110 102 111 # Sign a record from stdin 103 - echo '{"$type":"app.bsky.feed.post","text":"Hello"}' | cargo run --bin atproto-record-sign did:key:zQ3sh... did:plc:issuer123 -- repository=did:plc:user123 collection=app.bsky.feed.post 112 + echo '{"$type":"app.bsky.feed.post","text":"Hello AT Protocol!"}' | \ 113 + cargo run --bin atproto-record-sign did:key:zQ3sh... did:plc:issuer123 -- \ 114 + repository=did:plc:user123 collection=app.bsky.feed.post 115 + 116 + # Example output: JSON record with embedded signatures array 104 117 ``` 105 118 119 + **Arguments:** 120 + - `<signing_key>` - DID key string for signing (did:key:...) 121 + - `<issuer_did>` - DID of the signing entity 122 + - `<record_file>` - JSON file containing the record (optional, uses stdin if omitted) 123 + - `repository=<did>` - Repository DID where record will be stored 124 + - `collection=<nsid>` - Collection NSID (e.g., app.bsky.feed.post) 125 + 106 126 ### `atproto-record-verify` 107 - Verifies cryptographic signatures of AT Protocol records. Reads a signed JSON record from a file or stdin, validates the embedded cryptographic signatures using a public key, and reports whether the signature verification succeeds or fails. Uses IPLD DAG-CBOR deserialization for verification. 127 + 128 + Verifies cryptographic signatures of AT Protocol records using embedded signature metadata. This tool validates that signed records contain authentic signatures from specified issuers, ensuring record integrity and authenticity. 129 + 130 + **Features:** 131 + - **Signature Validation**: Verifies embedded signatures against public keys 132 + - **Issuer Authentication**: Confirms signatures are from specified DID issuers 133 + - **Context Verification**: Validates repository and collection context in signatures 134 + - **Multi-Signature Support**: Handles records with multiple signatures 135 + - **IPLD Deserialization**: Uses DAG-CBOR for signature verification consistency 136 + - **Detailed Error Reporting**: Provides specific feedback on verification failures 108 137 109 138 ```bash 110 139 # Verify a signed record from a file 111 - cargo run --bin atproto-record-verify did:plc:issuer123 did:key:zQ3sh... signed_record.json repository=did:plc:user123 collection=app.bsky.feed.post 140 + cargo run --bin atproto-record-verify did:plc:issuer123 did:key:zQ3sh... signed_record.json \ 141 + repository=did:plc:user123 collection=app.bsky.feed.post 112 142 113 143 # Verify a signed record from stdin 114 - echo '{"signatures":[...],"$type":"app.bsky.feed.post","text":"Hello"}' | cargo run --bin atproto-record-verify did:plc:issuer123 did:key:zQ3sh... -- repository=did:plc:user123 collection=app.bsky.feed.post 144 + echo '{"signatures":[{"issuer":"did:plc:issuer123","signature":"u..."}],"$type":"app.bsky.feed.post","text":"Hello"}' | \ 145 + cargo run --bin atproto-record-verify did:plc:issuer123 did:key:zQ3sh... -- \ 146 + repository=did:plc:user123 collection=app.bsky.feed.post 147 + 148 + # Successful verification returns exit code 0 149 + # Failed verification returns exit code 1 with error details 115 150 ``` 151 + 152 + **Arguments:** 153 + - `<issuer_did>` - DID of the expected signature issuer 154 + - `<public_key>` - DID key string for verification (did:key:...) 155 + - `<record_file>` - JSON file containing the signed record (optional, uses stdin if omitted) 156 + - `repository=<did>` - Repository DID context for verification 157 + - `collection=<nsid>` - Collection NSID context for verification 158 + 159 + **Exit Codes:** 160 + - `0` - Signature verification successful 161 + - `1` - Signature verification failed or invalid arguments 162 + - `2` - File I/O or parsing errors 116 163 117 164 ## Modules 118 165
+21 -1
crates/atproto-record/src/errors.rs
··· 6 6 //! 7 7 //! ## Error Categories 8 8 //! 9 - //! - **`VerificationError`** (verification-1 to verification-9): Record signature verification and creation errors 9 + //! - **`VerificationError`** (verification-1 to verification-11): Record signature verification and creation errors 10 10 //! - **`AturiError`** (aturi-1 to aturi-9): AT-URI parsing and validation errors 11 11 //! 12 12 //! ## Error Format ··· 101 101 KeyOperationFailed { 102 102 /// The underlying key operation error 103 103 #[from] 104 + error: atproto_identity::errors::KeyError, 105 + }, 106 + 107 + /// Error when signature decoding fails. 108 + /// 109 + /// This error occurs when the multibase-encoded signature cannot 110 + /// be decoded, typically due to invalid encoding format. 111 + #[error("error-atproto-record-verification-10 Signature decoding failed: {error}")] 112 + SignatureDecodingFailed { 113 + /// The underlying multibase decoding error 114 + error: multibase::Error, 115 + }, 116 + 117 + /// Error when cryptographic signature validation fails. 118 + /// 119 + /// This error occurs when the cryptographic validation of the signature 120 + /// fails, indicating either an invalid signature or mismatched key/data. 121 + #[error("error-atproto-record-verification-11 Cryptographic validation failed: {error}")] 122 + CryptographicValidationFailed { 123 + /// The underlying validation error 104 124 error: atproto_identity::errors::KeyError, 105 125 }, 106 126 }
+4 -11
crates/atproto-record/src/signature.rs
··· 4 4 //! elliptic curve digital signatures. Implements the AT Protocol signature format with proper 5 5 //! `$sig` object handling and IPLD DAG-CBOR serialization for secure record attestation. 6 6 7 - use anyhow::anyhow; 8 7 use atproto_identity::key::{sign, validate, KeyData}; 9 8 use serde_json::json; 10 9 ··· 178 177 let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record) 179 178 .map_err(|error| VerificationError::RecordSerializationFailed { error })?; 180 179 181 - let (_, signature_bytes) = multibase::decode(signature_value).map_err(|e| { 182 - VerificationError::SignatureVerificationFailed { 183 - error: anyhow!("error decoding signature: {}", e), 184 - } 185 - })?; 180 + let (_, signature_bytes) = multibase::decode(signature_value) 181 + .map_err(|error| VerificationError::SignatureDecodingFailed { error })?; 186 182 187 - validate(key_data, &signature_bytes, &serialized_record).map_err(|e| { 188 - VerificationError::SignatureVerificationFailed { 189 - error: anyhow!("error validating signature: {}", e), 190 - } 191 - })?; 183 + validate(key_data, &signature_bytes, &serialized_record) 184 + .map_err(|error| VerificationError::CryptographicValidationFailed { error })?; 192 185 193 186 return Ok(()); 194 187 }