Skip to main content

indieweb_cli_common/
auth.rs

1use axum::{
2    extract::Query,
3    response::{IntoResponse, Redirect},
4    routing::get,
5    Router,
6};
7use base64::Engine;
8use indieweb::standards::indieauth::{
9    AuthorizationRequestFields, Client, CommonRedemptionFields, RedemptionFields,
10    RedemptionResponse,
11};
12use rand::{rngs::OsRng, TryRngCore};
13use secrecy::{ExposeSecret, SecretString};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::net::SocketAddr;
17use std::sync::Arc;
18use tokio::sync::oneshot;
19use url::Url;
20
21use crate::config::IndieAuthConfig;
22use crate::error::{CliError, Result};
23use crate::token::TokenStore;
24
25type HttpClient = indieweb::http::reqwest::Client;
26type IndieAuthClient = Client<HttpClient>;
27
28#[derive(Debug, Clone, Deserialize)]
29struct CallbackParams {
30    code: String,
31    state: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AuthResult {
36    pub access_token: String,
37    pub me: String,
38    pub scope: Option<String>,
39    pub token_type: String,
40}
41
42struct SharedState {
43    code_verifier: String,
44    state: String,
45    tx: Option<oneshot::Sender<Result<String>>>,
46}
47
48pub async fn authenticate(
49    me: &Url,
50    config: &IndieAuthConfig,
51    scope: &str,
52) -> Result<AuthResult> {
53    let http_client = HttpClient::default();
54
55    let client_id = config.client_id.as_ref()
56        .map(|u| u.as_str())
57        .unwrap_or("https://cli.indieweb.org/");
58
59    let indieauth_client: IndieAuthClient = Client::<HttpClient>::builder()
60        .id(client_id)
61        .client(http_client)
62        .build()
63        .map_err(|e| CliError::AuthFailed(e.to_string()))?;
64
65    let metadata = indieauth_client
66        .obtain_metadata(me)
67        .await
68        .map_err(|e| CliError::AuthFailed(format!("Failed to obtain metadata: {}", e)))?;
69
70    let redirect_uri = config.redirect_uri.clone()
71        .unwrap_or_else(|| "http://localhost:8080/callback".parse().unwrap());
72
73    let (code_verifier, _code_challenge) = generate_pkce_challenge()?;
74
75    let scopes: indieweb::standards::indieauth::Scopes = scope.parse()
76        .map_err(|_| CliError::InvalidScope(scope.to_string()))?;
77
78    let state = generate_state()?;
79
80    let auth_request = AuthorizationRequestFields::new(
81        &indieauth_client.id,
82        &redirect_uri,
83        &state,
84    ).map_err(|e| CliError::AuthFailed(e.to_string()))?;
85
86    let auth_request = AuthorizationRequestFields {
87        scope: scopes,
88        ..auth_request
89    };
90
91    let auth_url = auth_request.into_authorization_url(
92        metadata.authorization_endpoint.clone(),
93        vec![("me".to_string(), me.to_string())],
94    ).map_err(|e| CliError::AuthFailed(e.to_string()))?;
95
96    let (tx, rx) = oneshot::channel();
97    let shared_state = Arc::new(tokio::sync::Mutex::new(SharedState {
98        code_verifier,
99        state: state.clone(),
100        tx: Some(tx),
101    }));
102
103    let port = redirect_uri.port().unwrap_or(8080);
104    let addr: SocketAddr = ([127, 0, 0, 1], port).into();
105
106    let app = Router::new()
107        .route("/callback", get({
108            let shared_state = shared_state.clone();
109            move |query: Query<CallbackParams>| {
110                let shared_state = shared_state.clone();
111                async move {
112                    let callback = query.0;
113                    let state = shared_state.lock().await;
114
115                    if callback.state.as_deref() != Some(&state.state) {
116                        if let Some(tx) = shared_state.lock().await.tx.take() {
117                            let _ = tx.send(Err(CliError::AuthFailed("State mismatch".to_string())));
118                        }
119                        return "State mismatch".into_response();
120                    }
121
122                    let code = callback.code.clone();
123
124                    if let Some(tx) = shared_state.lock().await.tx.take() {
125                        let _ = tx.send(Ok(code));
126                    }
127
128                    Redirect::temporary("data:text/html,<h1>Success! You can close this window.</h1>").into_response()
129                }
130            }
131        }));
132
133    println!("Opening browser for authorization...");
134    println!("If the browser doesn't open, visit: {}", auth_url);
135
136    open::that(auth_url.as_str())
137        .map_err(|e| CliError::AuthFailed(format!("Failed to open browser: {}", e)))?;
138
139    let listener = tokio::net::TcpListener::bind(addr)
140        .await
141        .map_err(|e| CliError::AuthFailed(format!("Failed to bind to port: {}", e)))?;
142
143    let server = axum::serve(listener, app);
144    let code = tokio::select! {
145        result = server => {
146            result.map_err(|e| CliError::AuthFailed(format!("Server error: {}", e)))?;
147            return Err(CliError::AuthCancelled);
148        }
149        result = rx => {
150            result.map_err(|_| CliError::AuthCancelled)??
151        }
152    };
153
154    let shared_state = shared_state.lock().await;
155
156    let redemption = RedemptionFields {
157        code,
158        client_id: indieauth_client.id.clone(),
159        redirect_uri: redirect_uri.into(),
160        verifier: shared_state.code_verifier.clone(),
161    };
162
163    let response: RedemptionResponse<CommonRedemptionFields> = indieauth_client
164        .redeem(&metadata.token_endpoint, redemption)
165        .await
166        .map_err(|e| CliError::AuthFailed(format!("Token redemption failed: {}", e)))?;
167
168    match response {
169        RedemptionResponse::Claim(claim) => Ok(AuthResult {
170            access_token: claim.access_token,
171            me: claim.me.to_string(),
172            scope: Some(claim.scope.to_string()),
173            token_type: "bearer".to_string(),
174        }),
175        RedemptionResponse::Error(e) => Err(CliError::AuthFailed(format!(
176            "{}: {}",
177            e.code,
178            e.description.unwrap_or_default()
179        ))),
180    }
181}
182
183fn generate_pkce_challenge() -> Result<(String, String)> {
184    let mut bytes = [0u8; 32];
185    OsRng.try_fill_bytes(&mut bytes)
186        .map_err(|e| CliError::AuthFailed(format!("Random generation failed: {:?}", e)))?;
187    let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes);
188
189    let mut hasher = Sha256::new();
190    hasher.update(verifier.as_bytes());
191    let hash = hasher.finalize();
192    let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&hash);
193
194    Ok((verifier, challenge))
195}
196
197fn generate_state() -> Result<String> {
198    let mut bytes = [0u8; 16];
199    OsRng.try_fill_bytes(&mut bytes)
200        .map_err(|e| CliError::AuthFailed(format!("Random generation failed: {:?}", e)))?;
201    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes))
202}
203
204pub async fn revoke_token(
205    token: &SecretString,
206    me: &Url,
207) -> Result<()> {
208    let http_client = HttpClient::default();
209
210    let indieauth_client: IndieAuthClient = Client::<HttpClient>::builder()
211        .id("https://cli.indieweb.org/")
212        .client(http_client)
213        .build()
214        .map_err(|e| CliError::AuthFailed(e.to_string()))?;
215
216    let metadata = indieauth_client
217        .obtain_metadata(me)
218        .await
219        .map_err(|e| CliError::AuthFailed(format!("Failed to obtain metadata: {}", e)))?;
220
221    let revocation_endpoint = metadata.revocation_endpoint
222        .ok_or(CliError::RevocationFailed)?;
223
224    indieauth_client
225        .revoke_token(&revocation_endpoint, token.expose_secret(), None)
226        .await
227        .map_err(|e: indieweb::Error| CliError::AuthFailed(format!("Revocation failed: {}", e)))?;
228
229    Ok(())
230}
231
232pub async fn introspect_token(
233    token: &SecretString,
234    me: &Url,
235) -> Result<serde_json::Value> {
236    let http_client = HttpClient::default();
237
238    let indieauth_client: IndieAuthClient = Client::<HttpClient>::builder()
239        .id("https://cli.indieweb.org/")
240        .client(http_client)
241        .build()
242        .map_err(|e| CliError::AuthFailed(e.to_string()))?;
243
244    let metadata = indieauth_client
245        .obtain_metadata(me)
246        .await
247        .map_err(|e| CliError::AuthFailed(format!("Failed to obtain metadata: {}", e)))?;
248
249    let introspection_endpoint = metadata.introspection_endpoint
250        .ok_or_else(|| CliError::AuthFailed("No introspection endpoint available".to_string()))?;
251
252    let response = indieauth_client
253        .introspect_token(&introspection_endpoint, token.expose_secret())
254        .await
255        .map_err(|e: indieweb::Error| CliError::AuthFailed(format!("Introspection failed: {}", e)))?;
256
257    Ok(serde_json::to_value(response).map_err(|e| CliError::AuthFailed(e.to_string()))?)
258}
259
260pub fn save_auth_result(result: &AuthResult, store: &TokenStore) -> Result<()> {
261    let token = SecretString::new(result.access_token.clone().into_boxed_str());
262    store.save(&token)
263}