xjp_oidc/
exchange.rs

1//! Authorization code exchange implementation (server-only)
2
3#[cfg(not(target_arch = "wasm32"))]
4use crate::{
5    discovery::discover,
6    errors::{Error, Result},
7    http::HttpClient,
8    types::{ExchangeCode, TokenResponse},
9};
10#[cfg(not(target_arch = "wasm32"))]
11use base64::{engine::general_purpose, Engine as _};
12
13/// Exchange authorization code for tokens
14///
15/// This is a server-only function that exchanges an authorization code
16/// for access token, refresh token (optional), and ID token (if openid scope).
17///
18/// # Example
19/// ```no_run
20/// # #[cfg(not(target_arch = "wasm32"))]
21/// # use xjp_oidc::{exchange_code, ExchangeCode, http::ReqwestHttpClient};
22/// # #[cfg(not(target_arch = "wasm32"))]
23/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
24/// let http = ReqwestHttpClient::default();
25///
26/// let tokens = exchange_code(ExchangeCode {
27///     issuer: "https://auth.example.com".into(),
28///     client_id: "my-client".into(),
29///     code: "auth_code".into(),
30///     redirect_uri: "https://app.example.com/callback".into(),
31///     code_verifier: Some("pkce_verifier".into()),
32///     client_secret: None, // For public clients
33///     token_endpoint_auth_method: None,
34/// }, &http).await?;
35/// # Ok(())
36/// # }
37/// ```
38#[cfg(not(target_arch = "wasm32"))]
39pub async fn exchange_code(params: ExchangeCode, http: &dyn HttpClient) -> Result<TokenResponse> {
40    // Validate parameters
41    validate_exchange_params(&params)?;
42
43    // Get token endpoint from discovery
44    let cache = crate::cache::NoOpCache;
45    let metadata = discover(&params.issuer, http, &cache).await?;
46
47    // Build form data
48    let mut form = vec![
49        ("grant_type".to_string(), "authorization_code".to_string()),
50        ("code".to_string(), params.code.clone()),
51        ("redirect_uri".to_string(), params.redirect_uri.clone()),
52    ];
53
54    // Add PKCE verifier if provided
55    if let Some(verifier) = &params.code_verifier {
56        if !verifier.is_empty() {
57            form.push(("code_verifier".to_string(), verifier.clone()));
58        }
59    }
60
61    // Determine authentication method based on token_endpoint_auth_method
62    let auth_method = params.token_endpoint_auth_method
63        .as_deref()
64        .unwrap_or(if params.client_secret.is_some() {
65            "client_secret_basic"
66        } else {
67            "none"
68        });
69
70    let auth_header = match auth_method {
71        "client_secret_basic" => {
72            if let Some(client_secret) = &params.client_secret {
73                let credentials = format!("{}:{}", params.client_id, client_secret);
74                let encoded = general_purpose::STANDARD.encode(credentials.as_bytes());
75                Some(("Authorization".to_string(), format!("Basic {}", encoded)))
76            } else {
77                return Err(Error::InvalidParam("client_secret_basic requires client_secret"));
78            }
79        },
80        "client_secret_post" => {
81            // Add client credentials to form data
82            form.push(("client_id".to_string(), params.client_id.clone()));
83            if let Some(client_secret) = &params.client_secret {
84                form.push(("client_secret".to_string(), client_secret.clone()));
85            } else {
86                return Err(Error::InvalidParam("client_secret_post requires client_secret"));
87            }
88            None
89        },
90        "none" | "public" => {
91            // Public client - include client_id in form
92            form.push(("client_id".to_string(), params.client_id.clone()));
93            None
94        },
95        "private_key_jwt" | "client_secret_jwt" => {
96            // TODO: Implement JWT-based authentication
97            // For now, return error indicating it's not supported
98            return Err(Error::InvalidParam(
99                "JWT-based authentication methods are not yet supported"
100            ));
101        },
102        _ => {
103            return Err(Error::InvalidParam(
104                "Unknown authentication method"
105            ));
106        }
107    };
108
109    // Make the token request
110    let response = http
111        .post_form_value(
112            &metadata.token_endpoint,
113            &form,
114            auth_header.as_ref().map(|(k, v)| (k.as_str(), v.as_str())),
115        )
116        .await
117        .map_err(|e| {
118            // Try to parse OAuth error from response
119            if let crate::http::HttpClientError::InvalidStatus { status: _, message } = &e {
120                if let Ok(oauth_error) = serde_json::from_str::<OAuthError>(&message) {
121                    return Error::oauth(oauth_error.error, oauth_error.error_description);
122                }
123            }
124            Error::Network(format!("Token exchange failed: {}", e))
125        })?;
126
127    // Parse token response
128    let tokens: TokenResponse = serde_json::from_value(response)?;
129
130    Ok(tokens)
131}
132
133/// Validate exchange parameters
134#[cfg(not(target_arch = "wasm32"))]
135fn validate_exchange_params(params: &ExchangeCode) -> Result<()> {
136    if params.issuer.is_empty() {
137        return Err(Error::InvalidParam("issuer cannot be empty"));
138    }
139    if params.client_id.is_empty() {
140        return Err(Error::InvalidParam("client_id cannot be empty"));
141    }
142    if params.code.is_empty() {
143        return Err(Error::InvalidParam("code cannot be empty"));
144    }
145    if params.redirect_uri.is_empty() {
146        return Err(Error::InvalidParam("redirect_uri cannot be empty"));
147    }
148    // PKCE is required for public clients, but optional for confidential clients
149    if params.client_secret.is_none() && params.code_verifier.as_ref().map_or(true, |v| v.is_empty()) {
150        return Err(Error::InvalidParam("code_verifier is required for public clients"));
151    }
152    Ok(())
153}
154
155/// OAuth error response
156#[cfg(not(target_arch = "wasm32"))]
157#[derive(serde::Deserialize)]
158struct OAuthError {
159    error: String,
160    error_description: Option<String>,
161}
162
163/// Exchange code with explicit token endpoint
164#[cfg(not(target_arch = "wasm32"))]
165#[allow(dead_code)]
166pub async fn exchange_code_with_endpoint(
167    params: ExchangeCode,
168    token_endpoint: &str,
169    http: &dyn HttpClient,
170) -> Result<TokenResponse> {
171    // Validate parameters
172    validate_exchange_params(&params)?;
173
174    // Build form data
175    let mut form = vec![
176        ("grant_type".to_string(), "authorization_code".to_string()),
177        ("code".to_string(), params.code.clone()),
178        ("redirect_uri".to_string(), params.redirect_uri.clone()),
179    ];
180
181    // Add PKCE verifier if provided
182    if let Some(verifier) = &params.code_verifier {
183        if !verifier.is_empty() {
184            form.push(("code_verifier".to_string(), verifier.clone()));
185        }
186    }
187
188    // Determine authentication method
189    let auth_header = if let Some(client_secret) = &params.client_secret {
190        // Check if we should use client_secret_post instead
191        let use_post = params
192            .token_endpoint_auth_method
193            .as_ref()
194            .map(|m| m == "client_secret_post")
195            .unwrap_or(false);
196
197        if use_post {
198            form.push(("client_id".to_string(), params.client_id.clone()));
199            form.push(("client_secret".to_string(), client_secret.clone()));
200            None
201        } else {
202            // Use client_secret_basic (default)
203            let credentials = format!("{}:{}", params.client_id, client_secret);
204            let encoded = general_purpose::STANDARD.encode(credentials.as_bytes());
205            Some(("Authorization".to_string(), format!("Basic {}", encoded)))
206        }
207    } else {
208        // Public client
209        form.push(("client_id".to_string(), params.client_id.clone()));
210        None
211    };
212
213    // Make the token request
214    let response = http
215        .post_form_value(
216            token_endpoint,
217            &form,
218            auth_header.as_ref().map(|(k, v)| (k.as_str(), v.as_str())),
219        )
220        .await
221        .map_err(|e| {
222            // Try to parse OAuth error from response
223            if let crate::http::HttpClientError::InvalidStatus { status: _, message } = &e {
224                if let Ok(oauth_error) = serde_json::from_str::<OAuthError>(&message) {
225                    return Error::oauth(oauth_error.error, oauth_error.error_description);
226                }
227            }
228            Error::Network(format!("Token exchange failed: {}", e))
229        })?;
230
231    // Parse token response
232    let tokens: TokenResponse = serde_json::from_value(response)?;
233
234    Ok(tokens)
235}
236
237
238/// Refresh an access token using a refresh token
239///
240/// This exchanges a refresh token for a new access token and optionally
241/// a new refresh token.
242///
243/// # Example
244/// ```no_run
245/// # #[cfg(not(target_arch = "wasm32"))]
246/// # use xjp_oidc::{refresh_token, RefreshTokenRequest, http::ReqwestHttpClient};
247/// # #[cfg(not(target_arch = "wasm32"))]
248/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
249/// let http = ReqwestHttpClient::default();
250///
251/// let tokens = refresh_token(RefreshTokenRequest {
252///     issuer: "https://auth.example.com".into(),
253///     client_id: "my-client".into(),
254///     client_secret: Some("my-secret".into()),
255///     refresh_token: "refresh_token_here".into(),
256///     scope: Some("openid profile email".into()),
257///     token_endpoint_auth_method: None,
258/// }, &http).await?;
259///
260/// println!("New access token: {}", tokens.access_token);
261/// # Ok(())
262/// # }
263/// ```
264#[cfg(not(target_arch = "wasm32"))]
265pub async fn refresh_token(
266    params: crate::types::RefreshTokenRequest,
267    http: &dyn HttpClient,
268) -> Result<TokenResponse> {
269    // Validate parameters
270    if params.issuer.is_empty() {
271        return Err(Error::InvalidParam("issuer cannot be empty"));
272    }
273    if params.client_id.is_empty() {
274        return Err(Error::InvalidParam("client_id cannot be empty"));
275    }
276    if params.refresh_token.is_empty() {
277        return Err(Error::InvalidParam("refresh_token cannot be empty"));
278    }
279
280    // Get token endpoint from discovery
281    let cache = crate::cache::NoOpCache;
282    let metadata = discover(&params.issuer, http, &cache).await?;
283
284    // Build form data
285    let mut form = vec![
286        ("grant_type".to_string(), "refresh_token".to_string()),
287        ("refresh_token".to_string(), params.refresh_token.clone()),
288    ];
289
290    // Add scope if provided
291    if let Some(scope) = &params.scope {
292        if !scope.is_empty() {
293            form.push(("scope".to_string(), scope.clone()));
294        }
295    }
296
297    // Determine authentication method based on token_endpoint_auth_method
298    let auth_method = params.token_endpoint_auth_method
299        .as_deref()
300        .unwrap_or(if params.client_secret.is_some() {
301            "client_secret_basic"
302        } else {
303            "none"
304        });
305
306    let auth_header = match auth_method {
307        "client_secret_basic" => {
308            if let Some(client_secret) = &params.client_secret {
309                let credentials = format!("{}:{}", params.client_id, client_secret);
310                let encoded = general_purpose::STANDARD.encode(credentials.as_bytes());
311                Some(("Authorization".to_string(), format!("Basic {}", encoded)))
312            } else {
313                return Err(Error::InvalidParam("client_secret_basic requires client_secret"));
314            }
315        },
316        "client_secret_post" => {
317            // Add client credentials to form data
318            form.push(("client_id".to_string(), params.client_id.clone()));
319            if let Some(client_secret) = &params.client_secret {
320                form.push(("client_secret".to_string(), client_secret.clone()));
321            } else {
322                return Err(Error::InvalidParam("client_secret_post requires client_secret"));
323            }
324            None
325        },
326        "none" | "public" => {
327            // Public client - include client_id in form
328            form.push(("client_id".to_string(), params.client_id.clone()));
329            None
330        },
331        "private_key_jwt" | "client_secret_jwt" => {
332            // TODO: Implement JWT-based authentication
333            return Err(Error::InvalidParam(
334                "JWT-based authentication methods are not yet supported"
335            ));
336        },
337        _ => {
338            return Err(Error::InvalidParam(
339                "Unknown authentication method"
340            ));
341        }
342    };
343
344    // Make the token request
345    let response = http
346        .post_form_value(
347            &metadata.token_endpoint,
348            &form,
349            auth_header.as_ref().map(|(k, v)| (k.as_str(), v.as_str())),
350        )
351        .await
352        .map_err(|e| {
353            // Try to parse OAuth error from response
354            if let crate::http::HttpClientError::InvalidStatus { status: _, message } = &e {
355                if let Ok(oauth_error) = serde_json::from_str::<OAuthError>(&message) {
356                    return Error::oauth(oauth_error.error, oauth_error.error_description);
357                }
358            }
359            Error::Network(format!("Token refresh failed: {}", e))
360        })?;
361
362    // Parse token response
363    let tokens: TokenResponse = serde_json::from_value(response)?;
364
365    Ok(tokens)
366}
367
368// WASM stubs
369#[cfg(target_arch = "wasm32")]
370pub async fn exchange_code(
371    _params: crate::types::ExchangeCode,
372    _http: &dyn crate::http::HttpClient,
373) -> crate::errors::Result<crate::types::TokenResponse> {
374    Err(crate::errors::Error::ServerOnly)
375}
376
377#[cfg(target_arch = "wasm32")]
378pub async fn refresh_token(
379    _params: crate::types::RefreshTokenRequest,
380    _http: &dyn crate::http::HttpClient,
381) -> crate::errors::Result<crate::types::TokenResponse> {
382    Err(crate::errors::Error::ServerOnly)
383}
384
385#[cfg(all(test, not(target_arch = "wasm32")))]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_validate_exchange_params() {
391        let valid = ExchangeCode {
392            issuer: "https://auth.example.com".into(),
393            client_id: "test-client".into(),
394            code: "auth_code".into(),
395            redirect_uri: "https://app.example.com/callback".into(),
396            code_verifier: Some("verifier".into()),
397            client_secret: None,
398            token_endpoint_auth_method: None,
399        };
400        assert!(validate_exchange_params(&valid).is_ok());
401
402        let invalid = ExchangeCode { issuer: "".into(), ..valid.clone() };
403        assert!(validate_exchange_params(&invalid).is_err());
404    }
405}