atproto_oauth_aip/
workflow.rs

1//! # OAuth 2.0 Workflow Implementation for AT Protocol Identity Providers
2//!
3//! This module provides a complete OAuth 2.0 authorization code flow implementation
4//! specifically designed for AT Protocol Identity Providers (AIPs). It handles the
5//! three main phases of OAuth authentication: initialization, completion, and session exchange.
6//!
7//! ## Workflow Overview
8//!
9//! The OAuth workflow consists of three main functions that handle different phases:
10//!
11//! 1. **Initialization (`oauth_init`)**: Creates a Pushed Authorization Request (PAR)
12//!    and returns the authorization URL for user consent
13//! 2. **Completion (`oauth_complete`)**: Exchanges the authorization code for access tokens
14//! 3. **Session Exchange (`session_exchange`)**: Converts OAuth tokens to AT Protocol sessions
15//!
16//! ## Security Features
17//!
18//! - **Pushed Authorization Requests (PAR)**: Enhanced security by storing authorization
19//!   parameters server-side rather than in redirect URLs
20//! - **PKCE (Proof Key for Code Exchange)**: Protection against authorization code
21//!   interception attacks
22//! - **DPoP (Demonstration of Proof-of-Possession)**: Cryptographic binding of tokens
23//!   to specific keys for enhanced security
24//!
25//! ## Usage Example
26//!
27//! ```rust,no_run
28//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
29//! use atproto_oauth_aip::workflow::{oauth_init, oauth_complete, session_exchange, OAuthClient};
30//! use atproto_oauth::resources::{AuthorizationServer, OAuthProtectedResource};
31//! use atproto_oauth::workflow::{OAuthRequestState, OAuthRequest};
32//!
33//! let http_client = reqwest::Client::new();
34//!
35//! // 1. Initialize OAuth flow
36//! let oauth_client = OAuthClient {
37//!     redirect_uri: "https://myapp.com/callback".to_string(),
38//!     client_id: "my_client_id".to_string(),
39//!     client_secret: "my_client_secret".to_string(),
40//! };
41//!
42//! # let authorization_server = AuthorizationServer {
43//! #     issuer: "https://auth.example.com".to_string(),
44//! #     authorization_endpoint: "https://auth.example.com/authorize".to_string(),
45//! #     token_endpoint: "https://auth.example.com/token".to_string(),
46//! #     pushed_authorization_request_endpoint: "https://auth.example.com/par".to_string(),
47//! #     introspection_endpoint: "".to_string(),
48//! #     scopes_supported: vec!["atproto".to_string(), "transition:generic".to_string()],
49//! #     response_types_supported: vec!["code".to_string()],
50//! #     grant_types_supported: vec!["authorization_code".to_string(), "refresh_token".to_string()],
51//! #     token_endpoint_auth_methods_supported: vec!["none".to_string(), "private_key_jwt".to_string()],
52//! #     token_endpoint_auth_signing_alg_values_supported: vec!["ES256".to_string()],
53//! #     require_pushed_authorization_requests: true,
54//! #     request_parameter_supported: false,
55//! #     code_challenge_methods_supported: vec!["S256".to_string()],
56//! #     authorization_response_iss_parameter_supported: true,
57//! #     dpop_signing_alg_values_supported: vec!["ES256".to_string()],
58//! #     client_id_metadata_document_supported: true,
59//! # };
60//!
61//! let oauth_request_state = OAuthRequestState {
62//!     state: "random-state".to_string(),
63//!     nonce: "random-nonce".to_string(),
64//!     code_challenge: "code-challenge".to_string(),
65//!     scope: "atproto transition:generic".to_string(),
66//! };
67//!
68//! let par_response = oauth_init(
69//!     &http_client,
70//!     &oauth_client,
71//!     Some("user.bsky.social"),
72//!     &authorization_server,
73//!     &oauth_request_state
74//! ).await?;
75//!
76//! // User visits auth_url and grants consent, returns with authorization code
77//!
78//! // 2. Complete OAuth flow
79//! # let oauth_request = OAuthRequest {
80//! #     oauth_state: "state".to_string(),
81//! #     issuer: "https://auth.example.com".to_string(),
82//! #     did: "did:plc:example".to_string(),
83//! #     nonce: "nonce".to_string(),
84//! #     signing_public_key: "public_key".to_string(),
85//! #     pkce_verifier: "verifier".to_string(),
86//! #     dpop_private_key: "private_key".to_string(),
87//! #     created_at: chrono::Utc::now(),
88//! #     expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
89//! # };
90//! let token_response = oauth_complete(
91//!     &http_client,
92//!     &oauth_client,
93//!     &authorization_server,
94//!     "received_auth_code",
95//!     &oauth_request
96//! ).await?;
97//!
98//! // 3. Exchange for AT Protocol session
99//! # let protected_resource = OAuthProtectedResource {
100//! #     resource: "https://pds.example.com".to_string(),
101//! #     scopes_supported: vec!["atproto".to_string()],
102//! #     bearer_methods_supported: vec!["header".to_string()],
103//! #     authorization_servers: vec!["https://auth.example.com".to_string()],
104//! # };
105//! let session = session_exchange(
106//!     &http_client,
107//!     &protected_resource,
108//!     &token_response.access_token
109//! ).await?;
110//! # Ok(())
111//! # }
112//! ```
113//!
114//! ## Error Handling
115//!
116//! All functions return `Result<T, OAuthWorkflowError>` with detailed error information
117//! for each phase of the OAuth flow including network failures, parsing errors,
118//! and protocol violations.
119
120use anyhow::Result;
121use atproto_oauth::{
122    resources::{AuthorizationServer, OAuthProtectedResource},
123    workflow::{OAuthRequest, OAuthRequestState, ParResponse, TokenResponse},
124};
125use serde::Deserialize;
126
127use crate::errors::OAuthWorkflowError;
128
129#[cfg(feature = "zeroize")]
130use zeroize::{Zeroize, ZeroizeOnDrop};
131
132/// OAuth client configuration containing essential client credentials.
133#[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))]
134pub struct OAuthClient {
135    /// The redirect URI where the authorization server will send the user after authorization.
136    #[cfg_attr(feature = "zeroize", zeroize(skip))]
137    pub redirect_uri: String,
138
139    /// The unique client identifier for this OAuth client.
140    #[cfg_attr(feature = "zeroize", zeroize(skip))]
141    pub client_id: String,
142
143    /// The client secret used for authenticating with the authorization server.
144    pub client_secret: String,
145}
146
147#[derive(Clone, Deserialize)]
148#[serde(untagged)]
149enum WrappedParResponse {
150    ParResponse(ParResponse),
151    Error {
152        error: String,
153        error_description: Option<String>,
154    },
155}
156
157/// Represents an authenticated AT Protocol session.
158///
159/// This structure contains all the information needed to make authenticated
160/// requests to AT Protocol services after a successful OAuth flow.
161#[derive(Clone, Deserialize)]
162#[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))]
163pub struct ATProtocolSession {
164    /// The Decentralized Identifier (DID) of the authenticated user.
165    #[cfg_attr(feature = "zeroize", zeroize(skip))]
166    pub did: String,
167
168    /// The handle (username) of the authenticated user.
169    #[cfg_attr(feature = "zeroize", zeroize(skip))]
170    pub handle: String,
171
172    /// The OAuth access token for making authenticated requests.
173    pub access_token: String,
174
175    /// The type of token (typically "Bearer").
176    pub token_type: String,
177
178    /// The list of OAuth scopes granted to this session.
179    #[cfg_attr(feature = "zeroize", zeroize(skip))]
180    pub scopes: Vec<String>,
181
182    /// The Personal Data Server (PDS) endpoint URL for this user.
183    #[cfg_attr(feature = "zeroize", zeroize(skip))]
184    pub pds_endpoint: String,
185
186    /// The DPoP (Demonstration of Proof-of-Possession) key in JWK format.
187    pub dpop_key: String,
188
189    /// Unix timestamp indicating when this session expires.
190    #[cfg_attr(feature = "zeroize", zeroize(skip))]
191    pub expires_at: i64,
192}
193
194#[derive(Deserialize, Clone)]
195#[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))]
196#[serde(untagged)]
197enum WrappedATProtocolSession {
198    ATProtocolSession(ATProtocolSession),
199
200    #[cfg_attr(feature = "zeroize", zeroize(skip))]
201    Error {
202        error: String,
203        error_description: Option<String>,
204    },
205}
206
207/// Initiates an OAuth authorization flow using Pushed Authorization Request (PAR).
208///
209/// This function starts the OAuth flow by sending a PAR request to the authorization
210/// server. PAR allows the client to push the authorization request parameters to the
211/// authorization server before redirecting the user, providing enhanced security.
212///
213/// # Arguments
214///
215/// * `http_client` - The HTTP client to use for making requests
216/// * `oauth_client` - OAuth client configuration with credentials
217/// * `handle` - Optional user handle to pre-fill in the login form
218/// * `authorization_server` - Authorization server metadata
219/// * `oauth_request_state` - OAuth request state including PKCE challenge and state
220///
221/// # Returns
222///
223/// Returns a `ParResponse` containing the request URI to redirect the user to,
224/// or an error if the PAR request fails.
225///
226/// # Example
227///
228/// ```no_run
229/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
230/// use atproto_oauth_aip::workflow::{oauth_init, OAuthClient};
231/// use atproto_oauth::workflow::OAuthRequestState;
232/// # let http_client = reqwest::Client::new();
233/// let oauth_client = OAuthClient {
234///     redirect_uri: "https://example.com/callback".to_string(),
235///     client_id: "client123".to_string(),
236///     client_secret: "secret456".to_string(),
237/// };
238/// # let authorization_server = todo!();
239/// let oauth_request_state = OAuthRequestState {
240///     state: "random-state".to_string(),
241///     nonce: "random-nonce".to_string(),
242///     code_challenge: "code-challenge".to_string(),
243///     scope: "atproto transition:generic".to_string(),
244/// };
245/// let par_response = oauth_init(
246///     &http_client,
247///     &oauth_client,
248///     Some("alice.bsky.social"),
249///     &authorization_server,
250///     &oauth_request_state,
251/// ).await?;
252/// # Ok(())
253/// # }
254/// ```
255pub async fn oauth_init(
256    http_client: &reqwest::Client,
257    oauth_client: &OAuthClient,
258    handle: Option<&str>,
259    authorization_server: &AuthorizationServer,
260    oauth_request_state: &OAuthRequestState,
261) -> Result<ParResponse> {
262    let par_url = authorization_server
263        .pushed_authorization_request_endpoint
264        .clone();
265
266    let scope = &oauth_request_state.scope;
267
268    let mut params = vec![
269        ("client_id", oauth_client.client_id.as_str()),
270        ("code_challenge_method", "S256"),
271        ("code_challenge", &oauth_request_state.code_challenge),
272        ("redirect_uri", oauth_client.redirect_uri.as_str()),
273        ("response_type", "code"),
274        ("scope", scope),
275        ("state", oauth_request_state.state.as_str()),
276    ];
277    if let Some(value) = handle {
278        params.push(("login_hint", value));
279    }
280
281    let response: WrappedParResponse = http_client
282        .post(par_url)
283        .form(&params)
284        .basic_auth(
285            oauth_client.client_id.as_str(),
286            Some(oauth_client.client_secret.as_str()),
287        )
288        .send()
289        .await
290        .map_err(OAuthWorkflowError::ParRequestFailed)?
291        .json()
292        .await
293        .map_err(OAuthWorkflowError::ParResponseParseFailed)?;
294
295    match response {
296        WrappedParResponse::ParResponse(value) => Ok(value),
297        WrappedParResponse::Error {
298            error,
299            error_description,
300        } => {
301            let error_message = if let Some(value) = error_description {
302                format!("{error}: {value}")
303            } else {
304                error.to_string()
305            };
306            Err(OAuthWorkflowError::ParResponseInvalid {
307                message: error_message,
308            }
309            .into())
310        }
311    }
312}
313
314/// Completes the OAuth authorization flow by exchanging the authorization code for tokens.
315///
316/// After the user has authorized the application and been redirected back with an
317/// authorization code, this function exchanges that code for access tokens using
318/// the token endpoint.
319///
320/// # Arguments
321///
322/// * `http_client` - The HTTP client to use for making requests
323/// * `oauth_client` - OAuth client configuration with credentials
324/// * `authorization_server` - Authorization server metadata
325/// * `callback_code` - The authorization code received in the callback
326/// * `oauth_request` - The original OAuth request containing the PKCE verifier
327///
328/// # Returns
329///
330/// Returns a `TokenResponse` containing the access token and other token information,
331/// or an error if the token exchange fails.
332///
333/// # Example
334///
335/// ```no_run
336/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
337/// use atproto_oauth_aip::workflow::oauth_complete;
338/// # let http_client = reqwest::Client::new();
339/// # let oauth_client = todo!();
340/// # let authorization_server = todo!();
341/// # let oauth_request = todo!();
342/// let token_response = oauth_complete(
343///     &http_client,
344///     &oauth_client,
345///     &authorization_server,
346///     "auth_code_from_callback",
347///     &oauth_request,
348/// ).await?;
349/// println!("Access token: {}", token_response.access_token);
350/// # Ok(())
351/// # }
352/// ```
353pub async fn oauth_complete(
354    http_client: &reqwest::Client,
355    oauth_client: &OAuthClient,
356    authorization_server: &AuthorizationServer,
357    callback_code: &str,
358    oauth_request: &OAuthRequest,
359) -> Result<TokenResponse> {
360    let params = [
361        ("client_id", oauth_client.client_id.as_str()),
362        ("redirect_uri", oauth_client.redirect_uri.as_str()),
363        ("grant_type", "authorization_code"),
364        ("code", callback_code),
365        ("code_verifier", &oauth_request.pkce_verifier),
366    ];
367
368    http_client
369        .post(&authorization_server.token_endpoint)
370        .basic_auth(
371            oauth_client.client_id.as_str(),
372            Some(oauth_client.client_secret.as_str()),
373        )
374        .form(&params)
375        .send()
376        .await
377        .map_err(OAuthWorkflowError::TokenRequestFailed)?
378        .json()
379        .await
380        .map_err(|e| OAuthWorkflowError::TokenResponseParseFailed(e).into())
381}
382
383/// Exchanges an OAuth access token for an AT Protocol session.
384///
385/// This function takes an OAuth access token and exchanges it for a full
386/// AT Protocol session, which includes additional information like the user's
387/// DID, handle, and PDS endpoint. This is specific to AT Protocol's OAuth
388/// implementation.
389///
390/// # Arguments
391///
392/// * `http_client` - The HTTP client to use for making requests
393/// * `protected_resource` - The protected resource metadata
394/// * `access_token` - The OAuth access token to exchange
395///
396/// # Returns
397///
398/// Returns an `ATProtocolSession` with full session information,
399/// or an error if the session exchange fails.
400///
401/// # Example
402///
403/// ```no_run
404/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
405/// use atproto_oauth_aip::workflow::session_exchange;
406/// # let http_client = reqwest::Client::new();
407/// # let protected_resource = todo!();
408/// # let access_token = "example_token";
409/// let session = session_exchange(
410///     &http_client,
411///     &protected_resource,
412///     access_token,
413/// ).await?;
414/// println!("Authenticated as {} ({})", session.handle, session.did);
415/// println!("PDS endpoint: {}", session.pds_endpoint);
416/// # Ok(())
417/// # }
418/// ```
419pub async fn session_exchange(
420    http_client: &reqwest::Client,
421    protected_resource: &OAuthProtectedResource,
422    access_token: &str,
423) -> Result<ATProtocolSession> {
424    let response = http_client
425        .get(format!(
426            "{}/api/atprotocol/session",
427            protected_resource.resource
428        ))
429        .bearer_auth(access_token)
430        .send()
431        .await
432        .map_err(OAuthWorkflowError::SessionRequestFailed)?
433        .json()
434        .await
435        .map_err(OAuthWorkflowError::SessionResponseParseFailed)?;
436
437    match response {
438        WrappedATProtocolSession::ATProtocolSession(ref value) => Ok(value.clone()),
439        WrappedATProtocolSession::Error {
440            ref error,
441            ref error_description,
442        } => {
443            let error_message = if let Some(value) = error_description {
444                format!("{error}: {value}")
445            } else {
446                error.to_string()
447            };
448            Err(OAuthWorkflowError::SessionResponseInvalid {
449                message: error_message,
450            }
451            .into())
452        }
453    }
454}