From 0eda41bca58a2fa58bbb2991f2ffbf3d600a545e Mon Sep 17 00:00:00 2001 From: Nick Gerakines Date: Wed, 18 Jun 2025 21:19:43 +0000 Subject: [PATCH] feature: atproto-oauth-aip crate Signed-off-by: Nick Gerakines --- Cargo.lock | 13 +++++++++++++ Cargo.toml | 3 +++ README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ crates/atproto-oauth-aip/Cargo.toml | 28 ++++++++++++++++++++++++++++ crates/atproto-oauth-aip/README.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ crates/atproto-oauth-aip/src/errors.rs | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ crates/atproto-oauth-aip/src/lib.rs | 9 +++++++++ crates/atproto-oauth-aip/src/resources.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ crates/atproto-oauth-aip/src/workflow.rs | 311 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ crates/atproto-oauth-axum/src/handle_complete.rs | 2 +- crates/atproto-oauth/src/dpop.rs | 78 +++++++++++++++++++++++++++++++++++++----------------------------------------- crates/atproto-oauth/src/workflow.rs | 2 +- crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs | 2 +- 13 file(s) changed, 778 insertion(s)(+), 44 deletion(s)(-) diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,19 @@ ] [[package]] +name = "atproto-oauth-aip" +version = "0.7.0" +dependencies = [ + "anyhow", + "atproto-oauth", + "chrono", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] name = "atproto-oauth-axum" version = "0.7.0" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ "crates/atproto-client", "crates/atproto-identity", "crates/atproto-jetstream", + "crates/atproto-oauth-aip", "crates/atproto-oauth-axum", "crates/atproto-oauth", "crates/atproto-record", @@ -24,6 +25,8 @@ atproto-client = { version = "0.7.0", path = "crates/atproto-client" } atproto-identity = { version = "0.7.0", path = "crates/atproto-identity" } atproto-oauth = { version = "0.7.0", path = "crates/atproto-oauth" } +atproto-oauth-axum = { version = "0.7.0", path = "crates/atproto-oauth-axum" } +atproto-oauth-aip = { version = "0.7.0", path = "crates/atproto-oauth-aip" } atproto-record = { version = "0.7.0", path = "crates/atproto-record" } atproto-xrpcs = { version = "0.7.0", path = "crates/atproto-xrpcs" } atproto-jetstream = { version = "0.7.0", path = "crates/atproto-jetstream" } diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - **[`atproto-identity`](crates/atproto-identity/)** - Identity management with DID resolution and cryptographic operations. Includes 4 CLI tools for identity resolution, key management, signing, and validation. - **[`atproto-record`](crates/atproto-record/)** - AT Protocol record signature operations. Includes 2 CLI tools for signing and verifying records. - **[`atproto-oauth`](crates/atproto-oauth/)** - OAuth 2.0 implementation with AT Protocol security extensions including PKCE, DPoP, and JWT operations. +- **[`atproto-oauth-aip`](crates/atproto-oauth-aip/)** - AT Protocol Identity Provider (AIP) OAuth workflow implementation for client applications. - **[`atproto-client`](crates/atproto-client/)** - HTTP client with DPoP authentication and repository operations. Includes 3 CLI tools for client authentication testing. ### Web Framework Integration @@ -32,6 +33,7 @@ atproto-identity = "0.7.0" atproto-record = "0.7.0" atproto-oauth = "0.7.0" +atproto-oauth-aip = "0.7.0" atproto-client = "0.7.0" # Add others as needed ``` @@ -123,6 +125,47 @@ let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?; axum::serve(listener, app).await?; + + Ok(()) +} +``` + +### OAuth Client Flow + +```rust +use atproto_oauth_aip::{OAuthClient, oauth_init, oauth_complete, session_exchange}; +use atproto_oauth::storage::MemoryStorage; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let client = OAuthClient::new( + "https://your-app.com/client-id".to_string(), + Some("your-client-secret".to_string()), + "https://your-app.com/callback".to_string(), + ); + + let storage = MemoryStorage::new(); + + // Start OAuth flow + let (authorization_url, state) = oauth_init( + &client, + "alice.bsky.social", + &storage, + ).await?; + + println!("Visit: {}", authorization_url); + + // After callback with code... + let access_token = oauth_complete( + &client, + "auth-code", + "state", + &storage, + ).await?; + + // Get AT Protocol session + let session = session_exchange(&client, &access_token, &storage).await?; + println!("Authenticated as: {} ({})", session.handle, session.did); Ok(()) } diff --git a/crates/atproto-oauth-aip/Cargo.toml b/crates/atproto-oauth-aip/Cargo.toml new file mode 100644 --- /dev/null +++ b/crates/atproto-oauth-aip/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "atproto-oauth-aip" +version = "0.7.0" +description = "ATProtocol AIP OAuth tools" +readme = "README.md" +homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs" +documentation = "https://docs.rs/atproto-oauth-aip" + +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +atproto-oauth.workspace = true + +anyhow.workspace = true +chrono.workspace = true +reqwest.workspace = true +serde_json.workspace = true +serde.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/atproto-oauth-aip/README.md b/crates/atproto-oauth-aip/README.md new file mode 100644 --- /dev/null +++ b/crates/atproto-oauth-aip/README.md @@ -0,0 +1,168 @@ +# atproto-oauth-aip + +AT Protocol OAuth implementation for AT Protocol Identity Provider (AIP) integration. This crate provides high-level OAuth workflow functions for client applications that need to authenticate with AT Protocol services. + +## Overview + +`atproto-oauth-aip` builds on top of the foundational `atproto-oauth` crate to provide a complete OAuth authentication workflow specifically tailored for AT Protocol. It handles the full OAuth flow including: + +- OAuth metadata discovery +- Pushed Authorization Request (PAR) initiation +- Authorization code exchange +- AT Protocol session establishment + +## Features + +- **PAR Support**: Enhanced security through Pushed Authorization Requests +- **AT Protocol Session Exchange**: Convert OAuth tokens to rich AT Protocol sessions +- **DPoP Integration**: Support for Demonstration of Proof-of-Possession tokens +- **Comprehensive Error Handling**: Typed errors for each OAuth operation +- **Async/Await**: Fully asynchronous implementation using Tokio + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +atproto-oauth-aip = "0.7" +``` + +## Usage + +### Basic OAuth Flow + +```rust +use atproto_oauth_aip::{OAuthClient, oauth_init, oauth_complete, session_exchange}; +use atproto_oauth::storage::MemoryStorage; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize OAuth client + let client = OAuthClient::new( + "https://your-app.com/client-id".to_string(), + Some("your-client-secret".to_string()), + "https://your-app.com/callback".to_string(), + ); + + // Initialize storage (use persistent storage in production) + let storage = MemoryStorage::new(); + + // Start OAuth flow + let (authorization_url, state) = oauth_init( + &client, + "user@example.com", // User identifier + &storage, + ).await?; + + // Redirect user to authorization_url + println!("Please visit: {}", authorization_url); + + // After user authorizes and is redirected back with code and state... + let authorization_code = "received-auth-code"; + let returned_state = "returned-state"; + + // Complete OAuth flow + let access_token = oauth_complete( + &client, + authorization_code, + returned_state, + &storage, + ).await?; + + // Exchange for AT Protocol session + let session = session_exchange( + &client, + &access_token, + &storage, + ).await?; + + println!("Authenticated as: {} ({})", session.handle, session.did); + println!("PDS Endpoint: {}", session.pds_endpoint); + + Ok(()) +} +``` + +### Fetching OAuth Metadata + +```rust +use atproto_oauth_aip::resources::{oauth_protected_resource, oauth_authorization_server}; + +// Get OAuth protected resource configuration +let protected_resource = oauth_protected_resource("https://bsky.social").await?; + +// Get OAuth authorization server metadata +let auth_server = oauth_authorization_server(&protected_resource).await?; +``` + +## API Documentation + +### Core Types + +- `OAuthClient`: OAuth client credentials and configuration +- `ATProtocolSession`: Authenticated session containing DID, handle, and PDS endpoint + +### Main Functions + +- `oauth_init()`: Initiates OAuth flow using PAR +- `oauth_complete()`: Exchanges authorization code for access token +- `session_exchange()`: Converts OAuth access token to AT Protocol session + +### Resource Functions + +- `oauth_protected_resource()`: Fetch OAuth protected resource metadata +- `oauth_authorization_server()`: Fetch OAuth authorization server metadata + +## Error Handling + +The crate uses typed errors following the AT Protocol error format: + +```rust +use atproto_oauth_aip::OAuthWorkflowError; + +match result { + Err(OAuthWorkflowError::InvalidAuthorizationRequest(e)) => { + // Handle PAR errors + } + Err(OAuthWorkflowError::TokenExchangeFailed(e)) => { + // Handle token exchange errors + } + // ... other error types +} +``` + +## Storage Requirements + +This crate requires an implementation of the `OAuthStorage` trait from `atproto-oauth`. For production use, implement persistent storage rather than using `MemoryStorage`. + +## Security Considerations + +- Always use HTTPS URLs for OAuth endpoints +- Implement proper state validation to prevent CSRF attacks +- Store client secrets securely +- Use persistent storage with appropriate security measures +- Validate DPoP keys when required by the authorization server + +## Dependencies + +This crate depends on: +- `atproto-oauth`: Core OAuth implementation +- `atproto-identity`: AT Protocol identity resolution +- `atproto-record`: Record handling +- `reqwest`: HTTP client +- `serde`: Serialization +- `tokio`: Async runtime + +## License + +Licensed under either of: + +- Apache License, Version 2.0 ([LICENSE-APACHE](../../LICENSE-APACHE)) +- MIT License ([LICENSE-MIT](../../LICENSE-MIT)) + +at your option. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. \ No newline at end of file diff --git a/crates/atproto-oauth-aip/src/errors.rs b/crates/atproto-oauth-aip/src/errors.rs new file mode 100644 --- /dev/null +++ b/crates/atproto-oauth-aip/src/errors.rs @@ -0,0 +1,61 @@ +//! # Structured Error Types for OAuth AIP Workflow +//! +//! Comprehensive error handling for AT Protocol OAuth AIP (Identity Provider) workflow operations +//! using structured error types with the `thiserror` library. All errors follow the project +//! convention of prefixed error codes with descriptive messages. +//! +//! ## Error Categories +//! +//! - **`OAuthWorkflowError`** (1 to 8): OAuth workflow operations including PAR, token exchange, and session management +//! +//! ## Error Format +//! +//! All errors use the standardized format: `error-atproto-oauth-aip-{number} {message}: {details}` + +use thiserror::Error; + +/// Errors that can occur during OAuth workflow operations. +/// +/// These errors represent failures in the complete OAuth authentication flow +/// for AT Protocol Identity Provider integration, including authorization +/// request initiation, token exchange, and session establishment. +#[derive(Debug, Error)] +pub enum OAuthWorkflowError { + /// Failed to send PAR HTTP request. + #[error("error-atproto-oauth-aip-1 PAR HTTP request failed: {0}")] + ParRequestFailed(#[source] reqwest::Error), + + /// Failed to parse PAR response JSON. + #[error("error-atproto-oauth-aip-2 PAR HTTP request parse failed: {0}")] + ParResponseParseFailed(#[source] reqwest::Error), + + /// PAR response contained an error. + #[error("error-atproto-oauth-aip-3 PAR HTTP response invalid: {message}")] + ParResponseInvalid { + /// Error message from the PAR response. + message: String, + }, + + /// Failed to send token exchange HTTP request. + #[error("error-atproto-oauth-aip-4 Token request failed: {0}")] + TokenRequestFailed(#[source] reqwest::Error), + + /// Failed to parse token response JSON. + #[error("error-atproto-oauth-aip-5 Token response json parsing failed: {0}")] + TokenResponseParseFailed(#[source] reqwest::Error), + + /// Failed to send session exchange HTTP request. + #[error("error-atproto-oauth-aip-6 Session request failed: {0}")] + SessionRequestFailed(#[source] reqwest::Error), + + /// Failed to parse session response JSON. + #[error("error-atproto-oauth-aip-7 Session json parsing failed: {0}")] + SessionResponseParseFailed(#[source] reqwest::Error), + + /// Session response contained an error. + #[error("error-atproto-oauth-aip-8 Session response invalid: {message}")] + SessionResponseInvalid { + /// Error message from the session response. + message: String, + }, +} diff --git a/crates/atproto-oauth-aip/src/lib.rs b/crates/atproto-oauth-aip/src/lib.rs new file mode 100644 --- /dev/null +++ b/crates/atproto-oauth-aip/src/lib.rs @@ -0,0 +1,9 @@ +//! AT Protocol OAuth AIP implementation. +#![warn(missing_docs)] + +/// Error types for OAuth workflow operations. +pub mod errors; +/// Resource definitions for OAuth operations. +pub mod resources; +/// OAuth workflow implementation. +pub mod workflow; diff --git a/crates/atproto-oauth-aip/src/resources.rs b/crates/atproto-oauth-aip/src/resources.rs new file mode 100644 --- /dev/null +++ b/crates/atproto-oauth-aip/src/resources.rs @@ -0,0 +1,102 @@ +use atproto_oauth::{ + errors::OAuthClientError, + resources::{AuthorizationServer, OAuthProtectedResource}, +}; + +/// Fetches OAuth protected resource metadata from an AIP server. +/// +/// This function retrieves the OAuth protected resource configuration from +/// the well-known endpoint of an AT Protocol Identity Provider (AIP) server. +/// The metadata includes information about the protected resource endpoints +/// and capabilities. +/// +/// # Arguments +/// +/// * `http_client` - The HTTP client to use for making the request +/// * `aip_server` - The base URL of the AIP server (e.g., "https://example.com") +/// +/// # Returns +/// +/// Returns the OAuth protected resource metadata on success, or an error if: +/// - The HTTP request fails +/// - The response cannot be parsed as valid OAuth protected resource metadata +/// +/// # Example +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// # let http_client = reqwest::Client::new(); +/// use atproto_oauth_aip::resources::oauth_protected_resource; +/// let resource = oauth_protected_resource(&http_client, "https://bsky.social").await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn oauth_protected_resource( + http_client: &reqwest::Client, + aip_server: &str, +) -> Result { + let destination = format!("{}/.well-known/oauth-protected-resource", aip_server); + + let resource: OAuthProtectedResource = http_client + .get(destination) + .send() + .await + .map_err(OAuthClientError::OAuthProtectedResourceRequestFailed)? + .json() + .await + .map_err(OAuthClientError::MalformedOAuthProtectedResourceResponse)?; + + Ok(resource) +} + +/// Fetches OAuth authorization server metadata from an AIP server. +/// +/// This function retrieves the OAuth authorization server configuration from +/// the well-known endpoint of an AT Protocol Identity Provider (AIP) server. +/// The metadata includes essential information such as: +/// - Authorization endpoint URL +/// - Token endpoint URL +/// - Pushed Authorization Request (PAR) endpoint URL +/// - Supported OAuth flows and capabilities +/// - JWKS (JSON Web Key Set) endpoint +/// +/// # Arguments +/// +/// * `http_client` - The HTTP client to use for making the request +/// * `aip_server` - The base URL of the AIP server (e.g., "https://example.com") +/// +/// # Returns +/// +/// Returns the OAuth authorization server metadata on success, or an error if: +/// - The HTTP request fails +/// - The response cannot be parsed as valid OAuth authorization server metadata +/// +/// # Example +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// # let http_client = reqwest::Client::new(); +/// use atproto_oauth_aip::resources::oauth_authorization_server; +/// let auth_server = oauth_authorization_server(&http_client, "https://bsky.social").await?; +/// println!("Authorization endpoint: {}", auth_server.authorization_endpoint); +/// println!("Token endpoint: {}", auth_server.token_endpoint); +/// # Ok(()) +/// # } +/// ``` +pub async fn oauth_authorization_server( + http_client: &reqwest::Client, + aip_server: &str, +) -> Result { + let destination = format!("{}/.well-known/oauth-authorization-server", aip_server); + + let resource: AuthorizationServer = http_client + .get(destination) + .send() + .await + .map_err(OAuthClientError::AuthorizationServerRequestFailed)? + .json() + .await + .map_err(OAuthClientError::MalformedAuthorizationServerResponse)?; + + Ok(resource) +} diff --git a/crates/atproto-oauth-aip/src/workflow.rs b/crates/atproto-oauth-aip/src/workflow.rs new file mode 100644 --- /dev/null +++ b/crates/atproto-oauth-aip/src/workflow.rs @@ -0,0 +1,311 @@ +use crate::errors::OAuthWorkflowError; +use anyhow::Result; +use atproto_oauth::{ + resources::{AuthorizationServer, OAuthProtectedResource}, + workflow::{OAuthRequest, OAuthRequestState, ParResponse, TokenResponse}, +}; +use serde::Deserialize; + +/// OAuth client configuration containing essential client credentials. +pub struct OAuthClient { + /// The redirect URI where the authorization server will send the user after authorization. + pub redirect_uri: String, + /// The unique client identifier for this OAuth client. + pub client_id: String, + + /// The client secret used for authenticating with the authorization server. + pub client_secret: String, +} + +#[derive(Clone, Deserialize)] +#[serde(untagged)] +enum WrappedParResponse { + ParResponse(ParResponse), + Error { + error: String, + error_description: Option, + }, +} + +/// Represents an authenticated AT Protocol session. +/// +/// This structure contains all the information needed to make authenticated +/// requests to AT Protocol services after a successful OAuth flow. +#[derive(Clone, Deserialize)] +pub struct ATProtocolSession { + /// The Decentralized Identifier (DID) of the authenticated user. + pub did: String, + /// The handle (username) of the authenticated user. + pub handle: String, + /// The OAuth access token for making authenticated requests. + pub access_token: String, + /// The type of token (typically "Bearer"). + pub token_type: String, + /// The list of OAuth scopes granted to this session. + pub scopes: Vec, + /// The Personal Data Server (PDS) endpoint URL for this user. + pub pds_endpoint: String, + /// The DPoP (Demonstration of Proof-of-Possession) key in JWK format. + pub dpop_key: String, + /// Unix timestamp indicating when this session expires. + pub expires_at: i64, +} + +#[derive(Deserialize, Clone)] +#[serde(untagged)] +enum WrappedATProtocolSession { + ATProtocolSession(ATProtocolSession), + Error { + error: String, + error_description: Option, + }, +} + +/// Initiates an OAuth authorization flow using Pushed Authorization Request (PAR). +/// +/// This function starts the OAuth flow by sending a PAR request to the authorization +/// server. PAR allows the client to push the authorization request parameters to the +/// authorization server before redirecting the user, providing enhanced security. +/// +/// # Arguments +/// +/// * `http_client` - The HTTP client to use for making requests +/// * `oauth_client` - OAuth client configuration with credentials +/// * `handle` - Optional user handle to pre-fill in the login form +/// * `authorization_server` - Authorization server metadata +/// * `oauth_request_state` - OAuth request state including PKCE challenge and state +/// +/// # Returns +/// +/// Returns a `ParResponse` containing the request URI to redirect the user to, +/// or an error if the PAR request fails. +/// +/// # Example +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// use atproto_oauth_aip::workflow::{oauth_init, OAuthClient}; +/// use atproto_oauth::workflow::OAuthRequestState; +/// # let http_client = reqwest::Client::new(); +/// let oauth_client = OAuthClient { +/// redirect_uri: "https://example.com/callback".to_string(), +/// client_id: "client123".to_string(), +/// client_secret: "secret456".to_string(), +/// }; +/// # let authorization_server = todo!(); +/// let oauth_request_state = OAuthRequestState { +/// state: "random-state".to_string(), +/// nonce: "random-nonce".to_string(), +/// code_challenge: "code-challenge".to_string(), +/// scope: "atproto transition:generic".to_string(), +/// }; +/// let par_response = oauth_init( +/// &http_client, +/// &oauth_client, +/// Some("alice.bsky.social"), +/// &authorization_server, +/// &oauth_request_state, +/// ).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn oauth_init( + http_client: &reqwest::Client, + oauth_client: &OAuthClient, + handle: Option<&str>, + authorization_server: &AuthorizationServer, + oauth_request_state: &OAuthRequestState, +) -> Result { + let par_url = authorization_server + .pushed_authorization_request_endpoint + .clone(); + + let scope = &oauth_request_state.scope; + + let mut params = vec![ + ("client_id", oauth_client.client_id.as_str()), + ("code_challenge_method", "S256"), + ("code_challenge", &oauth_request_state.code_challenge), + ("redirect_uri", oauth_client.redirect_uri.as_str()), + ("response_type", "code"), + ("scope", scope), + ("state", oauth_request_state.state.as_str()), + ]; + if let Some(value) = handle { + params.push(("login_hint", value)); + } + + let response: WrappedParResponse = http_client + .post(par_url) + .form(¶ms) + .basic_auth( + oauth_client.client_id.as_str(), + Some(oauth_client.client_secret.as_str()), + ) + .send() + .await + .map_err(OAuthWorkflowError::ParRequestFailed)? + .json() + .await + .map_err(OAuthWorkflowError::ParResponseParseFailed)?; + + match response { + WrappedParResponse::ParResponse(value) => Ok(value), + WrappedParResponse::Error { + error, + error_description, + } => { + let error_message = if let Some(value) = error_description { + format!("{error}: {value}") + } else { + error.to_string() + }; + Err(OAuthWorkflowError::ParResponseInvalid { + message: error_message, + } + .into()) + } + } +} + +/// Completes the OAuth authorization flow by exchanging the authorization code for tokens. +/// +/// After the user has authorized the application and been redirected back with an +/// authorization code, this function exchanges that code for access tokens using +/// the token endpoint. +/// +/// # Arguments +/// +/// * `http_client` - The HTTP client to use for making requests +/// * `oauth_client` - OAuth client configuration with credentials +/// * `authorization_server` - Authorization server metadata +/// * `callback_code` - The authorization code received in the callback +/// * `oauth_request` - The original OAuth request containing the PKCE verifier +/// +/// # Returns +/// +/// Returns a `TokenResponse` containing the access token and other token information, +/// or an error if the token exchange fails. +/// +/// # Example +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// use atproto_oauth_aip::workflow::oauth_complete; +/// # let http_client = reqwest::Client::new(); +/// # let oauth_client = todo!(); +/// # let authorization_server = todo!(); +/// # let oauth_request = todo!(); +/// let token_response = oauth_complete( +/// &http_client, +/// &oauth_client, +/// &authorization_server, +/// "auth_code_from_callback", +/// &oauth_request, +/// ).await?; +/// println!("Access token: {}", token_response.access_token); +/// # Ok(()) +/// # } +/// ``` +pub async fn oauth_complete( + http_client: &reqwest::Client, + oauth_client: &OAuthClient, + authorization_server: &AuthorizationServer, + callback_code: &str, + oauth_request: &OAuthRequest, +) -> Result { + let params = [ + ("client_id", oauth_client.client_id.as_str()), + ("redirect_uri", oauth_client.redirect_uri.as_str()), + ("grant_type", "authorization_code"), + ("code", callback_code), + ("code_verifier", &oauth_request.pkce_verifier), + ]; + + http_client + .post(&authorization_server.token_endpoint) + .basic_auth( + oauth_client.client_id.as_str(), + Some(oauth_client.client_secret.as_str()), + ) + .form(¶ms) + .send() + .await + .map_err(OAuthWorkflowError::TokenRequestFailed)? + .json() + .await + .map_err(|e| OAuthWorkflowError::TokenResponseParseFailed(e).into()) +} + +/// Exchanges an OAuth access token for an AT Protocol session. +/// +/// This function takes an OAuth access token and exchanges it for a full +/// AT Protocol session, which includes additional information like the user's +/// DID, handle, and PDS endpoint. This is specific to AT Protocol's OAuth +/// implementation. +/// +/// # Arguments +/// +/// * `http_client` - The HTTP client to use for making requests +/// * `protected_resource` - The protected resource metadata +/// * `access_token` - The OAuth access token to exchange +/// +/// # Returns +/// +/// Returns an `ATProtocolSession` with full session information, +/// or an error if the session exchange fails. +/// +/// # Example +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// use atproto_oauth_aip::workflow::session_exchange; +/// # let http_client = reqwest::Client::new(); +/// # let protected_resource = todo!(); +/// # let access_token = "example_token"; +/// let session = session_exchange( +/// &http_client, +/// &protected_resource, +/// access_token, +/// ).await?; +/// println!("Authenticated as {} ({})", session.handle, session.did); +/// println!("PDS endpoint: {}", session.pds_endpoint); +/// # Ok(()) +/// # } +/// ``` +pub async fn session_exchange( + http_client: &reqwest::Client, + protected_resource: &OAuthProtectedResource, + access_token: &str, +) -> Result { + let response = http_client + .get(format!( + "{}/api/atprotocol/session", + protected_resource.resource + )) + .bearer_auth(access_token) + .send() + .await + .map_err(OAuthWorkflowError::SessionRequestFailed)? + .json() + .await + .map_err(OAuthWorkflowError::SessionResponseParseFailed)?; + + match response { + WrappedATProtocolSession::ATProtocolSession(value) => Ok(value), + WrappedATProtocolSession::Error { + error, + error_description, + } => { + let error_message = if let Some(value) = error_description { + format!("{error}: {value}") + } else { + error.to_string() + }; + Err(OAuthWorkflowError::SessionResponseInvalid { + message: error_message, + } + .into()) + } + } +} diff --git a/crates/atproto-oauth-axum/src/handle_complete.rs b/crates/atproto-oauth-axum/src/handle_complete.rs --- a/crates/atproto-oauth-axum/src/handle_complete.rs +++ b/crates/atproto-oauth-axum/src/handle_complete.rs @@ -127,7 +127,7 @@ token_response.token_type, token_response.expires_in, token_response.scope, - token_response.sub, + token_response.sub.unwrap_or("unknown".to_string()), private_dpop_key_data ); diff --git a/crates/atproto-oauth/src/dpop.rs b/crates/atproto-oauth/src/dpop.rs --- a/crates/atproto-oauth/src/dpop.rs +++ b/crates/atproto-oauth/src/dpop.rs @@ -309,31 +309,7 @@ http_method: &str, http_uri: &str, ) -> anyhow::Result<(String, Header, Claims)> { - let now = chrono::Utc::now(); - - let public_key_data = to_public(key_data)?; - let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?; - - let header = Header { - type_: Some("dpop+jwt".to_string()), - algorithm: Some("ES256".to_string()), - json_web_key: Some(dpop_jwk), - ..Default::default() - }; - - let claims = Claims::new(JoseClaims { - json_web_token_id: Some(Ulid::new().to_string()), - http_method: Some(http_method.to_string()), - http_uri: Some(http_uri.to_string()), - issued_at: Some(now.timestamp() as u64), - expiration: Some((now + chrono::Duration::seconds(30)).timestamp() as u64), - nonce: Some(ulid::Ulid::new().to_string()), - ..Default::default() - }); - - let token = mint(key_data, &header, &claims)?; - - Ok((token, header, claims)) + build_dpop(key_data, http_method, http_uri, None) } /// Creates a DPoP proof token for OAuth resource requests. @@ -362,6 +338,15 @@ http_uri: &str, oauth_access_token: &str, ) -> anyhow::Result<(String, Header, Claims)> { + build_dpop(key_data, http_method, http_uri, Some(oauth_access_token)) +} + +fn build_dpop( + key_data: &KeyData, + http_method: &str, + http_uri: &str, + access_token: Option<&str>, +) -> anyhow::Result<(String, Header, Claims)> { let now = chrono::Utc::now(); let public_key_data = to_public(key_data)?; @@ -374,19 +359,19 @@ ..Default::default() }; - tracing::info!(?header, "request_dpop header"); + let auth = access_token.map(challenge); + let issued_at = Some(now.timestamp() as u64); + let expiration = Some((now + chrono::Duration::seconds(30)).timestamp() as u64); let claims = Claims::new(JoseClaims { - auth: Some(challenge(oauth_access_token)), - expiration: Some((now + chrono::Duration::seconds(30)).timestamp() as u64), + auth, + expiration, http_method: Some(http_method.to_string()), http_uri: Some(http_uri.to_string()), - issued_at: Some(now.timestamp() as u64), + issued_at, json_web_token_id: Some(Ulid::new().to_string()), ..Default::default() }); - - tracing::info!(?claims, "request_dpop claims"); let token = mint(key_data, &header, &claims)?; @@ -1392,18 +1377,21 @@ use atproto_identity::key::{KeyType, generate_key}; let key_data = generate_key(KeyType::P256Private)?; - let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; - - // Extract the nonce from the generated token - let parts: Vec<&str> = dpop_token.split('.').collect(); - let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?; - let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)?; - let nonce = payload.get("nonce").and_then(|v| v.as_str()).unwrap(); + + // Create a DPoP token with a nonce by manually building it + let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?; + + // Add nonce to claims + let test_nonce = "test_nonce_12345"; + claims.private.insert("nonce".to_string(), test_nonce.into()); + + // Create the token with nonce + let dpop_token = mint(&key_data, &header, &claims)?; // Create config with expected nonce values let mut config = DpopValidationConfig::for_authorization("POST", "https://example.com/token"); - config.expected_nonce_values = vec![nonce.to_string(), "other_nonce".to_string()]; + config.expected_nonce_values = vec![test_nonce.to_string(), "other_nonce".to_string()]; let thumbprint = validate_dpop_jwt(&dpop_token, &config)?; assert_eq!(thumbprint.len(), 43); @@ -1416,7 +1404,16 @@ use atproto_identity::key::{KeyType, generate_key}; let key_data = generate_key(KeyType::P256Private)?; - let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; + + // Create a DPoP token with a specific nonce + let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?; + + // Add a nonce that won't match the expected values + let token_nonce = "token_nonce_that_wont_match"; + claims.private.insert("nonce".to_string(), token_nonce.into()); + + // Create the token with nonce + let dpop_token = mint(&key_data, &header, &claims)?; // Create config with different nonce values (not matching the token) let mut config = @@ -1430,7 +1427,6 @@ assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("Invalid nonce")); - assert!(error_msg.contains("not in expected values")); Ok(()) } diff --git a/crates/atproto-oauth/src/workflow.rs b/crates/atproto-oauth/src/workflow.rs --- a/crates/atproto-oauth/src/workflow.rs +++ b/crates/atproto-oauth/src/workflow.rs @@ -179,7 +179,7 @@ /// The lifetime of the access token in seconds. pub expires_in: u32, /// The subject identifier (usually the user's DID). - pub sub: String, + pub sub: Option, /// Additional fields returned by the authorization server. #[serde(flatten)] diff --git a/crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs b/crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs --- a/crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs +++ b/crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs @@ -573,7 +573,7 @@ println!("Refresh Token: {}", &token_response.refresh_token); println!("Scope: {}", token_response.scope); println!("Expires In: {} seconds", token_response.expires_in); - println!("Subject: {}", token_response.sub); + println!("Subject: {}", token_response.sub.as_deref().unwrap_or("N/A")); println!("DPoP Key: {}", dpop_key); Ok(()) -- tangled.sh