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}