use std::time::{SystemTime, UNIX_EPOCH};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::{Error, Options, Result, is_local, split_origin};
pub const SCOPES: &str = "openid profile email offline_access urn:kernoid:data";
pub const APP_REDIRECT_URI: &str = "dev.kernoid.kernoid:/oauth";
#[derive(Debug, Clone)]
pub struct Login {
pub url: String,
issuer: String,
token_endpoint: String,
revocation_endpoint: Option<String>,
client_id: String,
redirect_uri: String,
verifier: String,
state: String,
}
#[derive(Debug, Clone)]
pub struct Tokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: u64,
pub token_endpoint: String,
pub client_id: String,
pub revocation_endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Saved {
pub refresh_token: String,
pub token_endpoint: String,
pub client_id: String,
#[serde(default)]
pub revocation_endpoint: Option<String>,
}
impl Tokens {
pub fn saved(&self) -> Option<Saved> {
Some(Saved {
refresh_token: self.refresh_token.clone()?,
token_endpoint: self.token_endpoint.clone(),
client_id: self.client_id.clone(),
revocation_endpoint: self.revocation_endpoint.clone(),
})
}
}
impl Saved {
pub fn tokens(&self) -> Tokens {
Tokens {
access_token: String::new(),
refresh_token: Some(self.refresh_token.clone()),
expires_at: 0,
token_endpoint: self.token_endpoint.clone(),
client_id: self.client_id.clone(),
revocation_endpoint: self.revocation_endpoint.clone(),
}
}
}
#[derive(Deserialize)]
struct Discovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
#[serde(default)]
revocation_endpoint: Option<String>,
#[serde(default)]
kernoid_apps_client_id: Option<String>,
}
#[derive(Deserialize)]
struct TokenAnswer {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: u64,
}
fn random(bytes: usize) -> Result<String> {
let mut buffer = vec![0; bytes];
getrandom::fill(&mut buffer).map_err(|error| Error::Transport(error.to_string()))?;
Ok(URL_SAFE_NO_PAD.encode(buffer))
}
fn http(origin: &str, options: &Options) -> Result<reqwest::Client> {
if !origin.starts_with("https://") && !(options.allow_http && origin.starts_with("http://")) && !is_local(origin) {
return Err(Error::Protocol(format!("{origin} is not HTTPS")));
}
crate::http(origin, &options.user_agent, Some(std::time::Duration::from_secs(60)))
}
fn origin_of(server: &str) -> Result<String> {
let server = server.trim().trim_end_matches('/');
let server = if server.contains("://") { server.to_owned() } else { format!("https://{server}") };
split_origin(&server)
.map(|(origin, _)| origin.to_owned())
.ok_or_else(|| Error::Protocol(format!("not a URL: {server}")))
}
pub async fn begin(server: &str, redirect_uri: &str, login_hint: Option<&str>, options: &Options) -> Result<Login> {
let origin = origin_of(server)?;
let response = http(&origin, options)?
.get(format!("{origin}/.well-known/openid-configuration"))
.header("accept", "application/json")
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Status(response.status().as_u16(), None));
}
let discovery: Discovery = response.json().await.map_err(|error| Error::Protocol(error.to_string()))?;
let client_id = discovery
.kernoid_apps_client_id
.ok_or_else(|| Error::Protocol("this server has no client for kernoid's apps".into()))?;
let host = |url: &str| split_origin(url).map(|(origin, _)| crate::host(origin).to_ascii_lowercase());
if host(&discovery.issuer) != host(&origin) {
return Err(Error::Protocol("the server names another issuer".into()));
}
let here = |endpoint: &str| -> Result<String> {
if host(endpoint) != host(&origin) {
return Err(Error::Protocol("the server's endpoints are on another host".into()));
}
Ok(format!("{origin}{}", split_origin(endpoint).map_or("/", |(_, path)| path)))
};
let (authorization_endpoint, token_endpoint) =
(here(&discovery.authorization_endpoint)?, here(&discovery.token_endpoint)?);
let revocation_endpoint = discovery.revocation_endpoint.as_deref().map(here).transpose()?;
let verifier = random(32)?;
let state = random(16)?;
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let mut url = url::Url::parse(&authorization_endpoint).map_err(|error| Error::Protocol(error.to_string()))?;
{
let mut query = url.query_pairs_mut();
query
.append_pair("response_type", "code")
.append_pair("client_id", &client_id)
.append_pair("redirect_uri", redirect_uri)
.append_pair("scope", SCOPES)
.append_pair("state", &state)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
if let Some(hint) = login_hint.map(str::trim).filter(|hint| !hint.is_empty()) {
query.append_pair("login_hint", hint);
}
}
Ok(Login {
url: url.into(),
issuer: discovery.issuer,
token_endpoint,
revocation_endpoint,
client_id,
redirect_uri: redirect_uri.to_owned(),
verifier,
state,
})
}
pub async fn finish(login: &Login, callback: &str, options: &Options) -> Result<Tokens> {
let url = url::Url::parse(callback).map_err(|_| Error::Protocol("not the URL of a login".into()))?;
let params: std::collections::HashMap<String, String> = url.query_pairs().into_owned().collect();
if params.get("state") != Some(&login.state) {
return Err(Error::Protocol("the login came back for another request".into()));
}
if let Some(iss) = params.get("iss")
&& iss != &login.issuer
{
return Err(Error::Protocol("the login came back from another server".into()));
}
if let Some(error) = params.get("error") {
let description = params.get("error_description").cloned();
return Err(Error::Method { kind: error.clone(), description });
}
let code = params.get("code").ok_or_else(|| Error::Protocol("the login came back without a code".into()))?;
let form = [
("grant_type", "authorization_code"),
("code", code.as_str()),
("redirect_uri", login.redirect_uri.as_str()),
("code_verifier", login.verifier.as_str()),
("client_id", login.client_id.as_str()),
];
tokens(&login.token_endpoint, &login.client_id, login.revocation_endpoint.as_deref(), &form, options).await
}
pub async fn refresh(tokens: &Tokens, options: &Options) -> Result<Tokens> {
let refresh_token =
tokens.refresh_token.as_deref().ok_or_else(|| Error::Protocol("the login has no refresh token".into()))?;
let form =
[("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", tokens.client_id.as_str())];
let mut new =
self::tokens(&tokens.token_endpoint, &tokens.client_id, tokens.revocation_endpoint.as_deref(), &form, options)
.await?;
if new.refresh_token.is_none() {
new.refresh_token = tokens.refresh_token.clone();
}
Ok(new)
}
async fn tokens(
endpoint: &str,
client_id: &str,
revocation_endpoint: Option<&str>,
form: &[(&str, &str)],
options: &Options,
) -> Result<Tokens> {
let (origin, _) = split_origin(endpoint).ok_or_else(|| Error::Protocol(format!("not a URL: {endpoint}")))?;
let body = url::form_urlencoded::Serializer::new(String::new()).extend_pairs(form).finish();
let response = http(origin, options)?
.post(endpoint)
.header("content-type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await?;
let status = response.status().as_u16();
let body: Value = response.json().await.map_err(|error| Error::Protocol(error.to_string()))?;
if status != 200 {
let kind = body.get("error").and_then(Value::as_str).unwrap_or("invalid_grant").to_owned();
if kind == "invalid_grant" {
return Err(Error::Unauthorized);
}
let description = body.get("error_description").and_then(Value::as_str).map(str::to_owned);
return Err(Error::Method { kind, description });
}
let answer: TokenAnswer = serde_json::from_value(body).map_err(|error| Error::Protocol(error.to_string()))?;
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs());
Ok(Tokens {
access_token: answer.access_token,
refresh_token: answer.refresh_token,
expires_at: now + answer.expires_in,
token_endpoint: endpoint.to_owned(),
client_id: client_id.to_owned(),
revocation_endpoint: revocation_endpoint.map(str::to_owned),
})
}
pub async fn revoke(tokens: &Tokens, options: &Options) -> Result<()> {
let (Some(endpoint), Some(refresh_token)) = (&tokens.revocation_endpoint, &tokens.refresh_token) else {
return Ok(());
};
let (origin, _) = split_origin(endpoint).ok_or_else(|| Error::Protocol(format!("not a URL: {endpoint}")))?;
let form =
[("token", refresh_token.as_str()), ("token_type_hint", "refresh_token"), ("client_id", &tokens.client_id)];
let body = url::form_urlencoded::Serializer::new(String::new()).extend_pairs(form).finish();
let response = http(origin, options)?
.post(endpoint)
.header("content-type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await?;
match response.status().as_u16() {
200 => Ok(()),
status => Err(Error::Status(status, None)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn origins_as_people_type_them() {
assert_eq!(origin_of("login.example").unwrap(), "https://login.example");
assert_eq!(origin_of("http://apps.localhost:18382/").unwrap(), "http://apps.localhost:18382");
assert_eq!(origin_of("https://a.example/jmap/session").unwrap(), "https://a.example");
}
#[test]
fn verifiers_are_long_and_fresh() {
let (a, b) = (random(32).unwrap(), random(32).unwrap());
assert_eq!(a.len(), 43);
assert_ne!(a, b);
}
}