+4
-4
CLAUDE.prompts.md
+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
+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
+1
-1
Cargo.toml
+202
-92
README.md
+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
+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
+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
+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
+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
+6
crates/atproto-identity/src/axum/mod.rs
+49
crates/atproto-identity/src/axum/state.rs
+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
+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
+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
+3
crates/atproto-identity/src/lib.rs
+38
crates/atproto-oauth-axum/Cargo.toml
+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
+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
+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
+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
+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
+4
crates/atproto-oauth-axum/src/handle_init.rs
+32
crates/atproto-oauth-axum/src/handle_jwks.rs
+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
+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
+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
+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
+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
+6
crates/atproto-oauth/src/axum/mod.rs
+29
crates/atproto-oauth/src/axum/state.rs
+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
+3
crates/atproto-oauth/src/lib.rs
+54
-7
crates/atproto-record/README.md
+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
+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
-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
}