1use std::time::{SystemTime, UNIX_EPOCH};
12
13use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17
18use crate::{Error, Options, Result, is_local, split_origin};
19
20pub const SCOPES: &str = "openid profile email offline_access urn:kernoid:data";
22
23pub const APP_REDIRECT_URI: &str = "dev.kernoid.kernoid:/oauth";
26
27#[derive(Debug, Clone)]
29pub struct Login {
30 pub url: String,
31 issuer: String,
32 token_endpoint: String,
33 revocation_endpoint: Option<String>,
34 client_id: String,
35 redirect_uri: String,
36 verifier: String,
37 state: String,
38}
39
40#[derive(Debug, Clone)]
43pub struct Tokens {
44 pub access_token: String,
45 pub refresh_token: Option<String>,
46 pub expires_at: u64,
47 pub token_endpoint: String,
49 pub client_id: String,
50 pub revocation_endpoint: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Saved {
59 pub refresh_token: String,
60 pub token_endpoint: String,
61 pub client_id: String,
62 #[serde(default)]
63 pub revocation_endpoint: Option<String>,
64}
65
66impl Tokens {
67 pub fn saved(&self) -> Option<Saved> {
69 Some(Saved {
70 refresh_token: self.refresh_token.clone()?,
71 token_endpoint: self.token_endpoint.clone(),
72 client_id: self.client_id.clone(),
73 revocation_endpoint: self.revocation_endpoint.clone(),
74 })
75 }
76}
77
78impl Saved {
79 pub fn tokens(&self) -> Tokens {
81 Tokens {
82 access_token: String::new(),
83 refresh_token: Some(self.refresh_token.clone()),
84 expires_at: 0,
85 token_endpoint: self.token_endpoint.clone(),
86 client_id: self.client_id.clone(),
87 revocation_endpoint: self.revocation_endpoint.clone(),
88 }
89 }
90}
91
92#[derive(Deserialize)]
93struct Discovery {
94 issuer: String,
95 authorization_endpoint: String,
96 token_endpoint: String,
97 #[serde(default)]
98 revocation_endpoint: Option<String>,
99 #[serde(default)]
100 kernoid_apps_client_id: Option<String>,
101}
102
103#[derive(Deserialize)]
104struct TokenAnswer {
105 access_token: String,
106 #[serde(default)]
107 refresh_token: Option<String>,
108 #[serde(default)]
109 expires_in: u64,
110}
111
112fn random(bytes: usize) -> Result<String> {
113 let mut buffer = vec![0; bytes];
114 getrandom::fill(&mut buffer).map_err(|error| Error::Transport(error.to_string()))?;
115 Ok(URL_SAFE_NO_PAD.encode(buffer))
116}
117
118fn http(origin: &str, options: &Options) -> Result<reqwest::Client> {
119 if !origin.starts_with("https://") && !(options.allow_http && origin.starts_with("http://")) && !is_local(origin) {
120 return Err(Error::Protocol(format!("{origin} is not HTTPS")));
121 }
122 crate::http(origin, &options.user_agent, Some(std::time::Duration::from_secs(60)))
123}
124
125fn origin_of(server: &str) -> Result<String> {
127 let server = server.trim().trim_end_matches('/');
128 let server = if server.contains("://") { server.to_owned() } else { format!("https://{server}") };
129 split_origin(&server)
130 .map(|(origin, _)| origin.to_owned())
131 .ok_or_else(|| Error::Protocol(format!("not a URL: {server}")))
132}
133
134pub async fn begin(server: &str, redirect_uri: &str, login_hint: Option<&str>, options: &Options) -> Result<Login> {
137 let origin = origin_of(server)?;
138 let response = http(&origin, options)?
139 .get(format!("{origin}/.well-known/openid-configuration"))
140 .header("accept", "application/json")
141 .send()
142 .await?;
143 if !response.status().is_success() {
144 return Err(Error::Status(response.status().as_u16(), None));
145 }
146 let discovery: Discovery = response.json().await.map_err(|error| Error::Protocol(error.to_string()))?;
147 let client_id = discovery
148 .kernoid_apps_client_id
149 .ok_or_else(|| Error::Protocol("this server has no client for kernoid's apps".into()))?;
150 let host = |url: &str| split_origin(url).map(|(origin, _)| crate::host(origin).to_ascii_lowercase());
153 if host(&discovery.issuer) != host(&origin) {
154 return Err(Error::Protocol("the server names another issuer".into()));
155 }
156 let here = |endpoint: &str| -> Result<String> {
157 if host(endpoint) != host(&origin) {
158 return Err(Error::Protocol("the server's endpoints are on another host".into()));
159 }
160 Ok(format!("{origin}{}", split_origin(endpoint).map_or("/", |(_, path)| path)))
161 };
162 let (authorization_endpoint, token_endpoint) =
163 (here(&discovery.authorization_endpoint)?, here(&discovery.token_endpoint)?);
164 let revocation_endpoint = discovery.revocation_endpoint.as_deref().map(here).transpose()?;
165 let verifier = random(32)?;
166 let state = random(16)?;
167 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
168 let mut url = url::Url::parse(&authorization_endpoint).map_err(|error| Error::Protocol(error.to_string()))?;
169 {
170 let mut query = url.query_pairs_mut();
171 query
172 .append_pair("response_type", "code")
173 .append_pair("client_id", &client_id)
174 .append_pair("redirect_uri", redirect_uri)
175 .append_pair("scope", SCOPES)
176 .append_pair("state", &state)
177 .append_pair("code_challenge", &challenge)
178 .append_pair("code_challenge_method", "S256");
179 if let Some(hint) = login_hint.map(str::trim).filter(|hint| !hint.is_empty()) {
180 query.append_pair("login_hint", hint);
181 }
182 }
183 Ok(Login {
184 url: url.into(),
185 issuer: discovery.issuer,
186 token_endpoint,
187 revocation_endpoint,
188 client_id,
189 redirect_uri: redirect_uri.to_owned(),
190 verifier,
191 state,
192 })
193}
194
195pub async fn finish(login: &Login, callback: &str, options: &Options) -> Result<Tokens> {
198 let url = url::Url::parse(callback).map_err(|_| Error::Protocol("not the URL of a login".into()))?;
199 let params: std::collections::HashMap<String, String> = url.query_pairs().into_owned().collect();
200 if params.get("state") != Some(&login.state) {
201 return Err(Error::Protocol("the login came back for another request".into()));
202 }
203 if let Some(iss) = params.get("iss")
204 && iss != &login.issuer
205 {
206 return Err(Error::Protocol("the login came back from another server".into()));
207 }
208 if let Some(error) = params.get("error") {
209 let description = params.get("error_description").cloned();
210 return Err(Error::Method { kind: error.clone(), description });
211 }
212 let code = params.get("code").ok_or_else(|| Error::Protocol("the login came back without a code".into()))?;
213 let form = [
214 ("grant_type", "authorization_code"),
215 ("code", code.as_str()),
216 ("redirect_uri", login.redirect_uri.as_str()),
217 ("code_verifier", login.verifier.as_str()),
218 ("client_id", login.client_id.as_str()),
219 ];
220 tokens(&login.token_endpoint, &login.client_id, login.revocation_endpoint.as_deref(), &form, options).await
221}
222
223pub async fn refresh(tokens: &Tokens, options: &Options) -> Result<Tokens> {
225 let refresh_token =
226 tokens.refresh_token.as_deref().ok_or_else(|| Error::Protocol("the login has no refresh token".into()))?;
227 let form =
228 [("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", tokens.client_id.as_str())];
229 let mut new =
230 self::tokens(&tokens.token_endpoint, &tokens.client_id, tokens.revocation_endpoint.as_deref(), &form, options)
231 .await?;
232 if new.refresh_token.is_none() {
234 new.refresh_token = tokens.refresh_token.clone();
235 }
236 Ok(new)
237}
238
239async fn tokens(
240 endpoint: &str,
241 client_id: &str,
242 revocation_endpoint: Option<&str>,
243 form: &[(&str, &str)],
244 options: &Options,
245) -> Result<Tokens> {
246 let (origin, _) = split_origin(endpoint).ok_or_else(|| Error::Protocol(format!("not a URL: {endpoint}")))?;
247 let body = url::form_urlencoded::Serializer::new(String::new()).extend_pairs(form).finish();
248 let response = http(origin, options)?
249 .post(endpoint)
250 .header("content-type", "application/x-www-form-urlencoded")
251 .body(body)
252 .send()
253 .await?;
254 let status = response.status().as_u16();
255 let body: Value = response.json().await.map_err(|error| Error::Protocol(error.to_string()))?;
256 if status != 200 {
257 let kind = body.get("error").and_then(Value::as_str).unwrap_or("invalid_grant").to_owned();
258 if kind == "invalid_grant" {
259 return Err(Error::Unauthorized);
260 }
261 let description = body.get("error_description").and_then(Value::as_str).map(str::to_owned);
262 return Err(Error::Method { kind, description });
263 }
264 let answer: TokenAnswer = serde_json::from_value(body).map_err(|error| Error::Protocol(error.to_string()))?;
265 let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs());
266 Ok(Tokens {
267 access_token: answer.access_token,
268 refresh_token: answer.refresh_token,
269 expires_at: now + answer.expires_in,
270 token_endpoint: endpoint.to_owned(),
271 client_id: client_id.to_owned(),
272 revocation_endpoint: revocation_endpoint.map(str::to_owned),
273 })
274}
275
276pub async fn revoke(tokens: &Tokens, options: &Options) -> Result<()> {
279 let (Some(endpoint), Some(refresh_token)) = (&tokens.revocation_endpoint, &tokens.refresh_token) else {
280 return Ok(());
281 };
282 let (origin, _) = split_origin(endpoint).ok_or_else(|| Error::Protocol(format!("not a URL: {endpoint}")))?;
283 let form =
284 [("token", refresh_token.as_str()), ("token_type_hint", "refresh_token"), ("client_id", &tokens.client_id)];
285 let body = url::form_urlencoded::Serializer::new(String::new()).extend_pairs(form).finish();
286 let response = http(origin, options)?
287 .post(endpoint)
288 .header("content-type", "application/x-www-form-urlencoded")
289 .body(body)
290 .send()
291 .await?;
292 match response.status().as_u16() {
293 200 => Ok(()),
294 status => Err(Error::Status(status, None)),
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn origins_as_people_type_them() {
304 assert_eq!(origin_of("login.example").unwrap(), "https://login.example");
305 assert_eq!(origin_of("http://apps.localhost:18382/").unwrap(), "http://apps.localhost:18382");
306 assert_eq!(origin_of("https://a.example/jmap/session").unwrap(), "https://a.example");
307 }
308
309 #[test]
310 fn verifiers_are_long_and_fresh() {
311 let (a, b) = (random(32).unwrap(), random(32).unwrap());
312 assert_eq!(a.len(), 43);
313 assert_ne!(a, b);
314 }
315}