Skip to main content

kernoid_client/
oidc.rs

1// SPDX-License-Identifier: MIT
2//! Logging in with OpenID Connect, as kernoid's own apps do (RFC 0001, 7.2; RFC 8252): the authorization code flow
3//! with PKCE in the system's browser – where kernoid's login page asks for the passkey, the password or a second
4//! factor –, kernoid's built-in client of the apps (`kernoid_apps_client_id` in the discovery document), and the
5//! scope `urn:kernoid:data`, with which the access token reaches JMAP, CalDAV, CardDAV and WebDAV. `offline_access`
6//! gives a refresh token, so that the app stays logged in.
7//!
8//! [`begin`] gives the URL to open and what to keep; the app opens it (`ASWebAuthenticationSession`, Custom Tabs, the
9//! default browser with a loopback listener) and hands the URL it is sent back to [`finish`].
10
11use 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
20/// What kernoid's apps ask for.
21pub const SCOPES: &str = "openid profile email offline_access urn:kernoid:data";
22
23/// The URI kernoid's apps are sent back to on Apple's systems and Android (their private scheme, RFC 8252, 7.1); on
24/// desktops a loopback address with a port of the app's choosing (7.3): `http://127.0.0.1:<port>/oauth`.
25pub const APP_REDIRECT_URI: &str = "dev.kernoid.kernoid:/oauth";
26
27/// A login begun: the URL for the browser, and what [`finish`] needs. Keep it until the browser comes back.
28#[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/// The tokens of a login. `refresh_token` renews them ([`refresh`]); `expires_at` is the access token's end, in
41/// seconds since 1970.
42#[derive(Debug, Clone)]
43pub struct Tokens {
44    pub access_token: String,
45    pub refresh_token: Option<String>,
46    pub expires_at: u64,
47    /// For the token endpoint and client id that renew them.
48    pub token_endpoint: String,
49    pub client_id: String,
50    /// Where [`revoke`] ends the login (RFC 7009), if the server has one.
51    pub revocation_endpoint: Option<String>,
52}
53
54/// What an app keeps of a login to resume it after a restart – in the system's key store, as it is a credential:
55/// the refresh token and where it is used, never the access token. kernoid rotates refresh tokens, so it is kept
56/// again after every [`refresh`]; presenting one used before ends the login (OAuth 2.0 Security BCP, 4.14).
57#[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    /// What to keep of these tokens; none without a refresh token.
68    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    /// Tokens to renew: the access token is empty until [`refresh`] gives one.
80    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
125/// The origin of a server as the user gives it: `https://host`, a URL of it, or the host alone.
126fn 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
134/// Begins a login at `server` for kernoid's own apps, to come back to `redirect_uri` ([`APP_REDIRECT_URI`] or a
135/// loopback address); `login_hint` fills in the address on the login page.
136pub 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    // The issuer is the server asked – its host; the endpoints are used on the origin asked, as the JMAP client keeps
151    // to it, so nothing goes elsewhere (and a server behind a proxy that names no port still works).
152    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
195/// Finishes a login with the URL the browser was sent back to: checks `state` and `iss` (RFC 9207), and exchanges
196/// the code for tokens.
197pub 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
223/// New tokens for a refresh token; the one used is used up (kernoid rotates them).
224pub 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    // A server may keep the refresh token as it is.
233    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
276/// Ends a login: revokes the refresh token – and with it its access tokens – at the server (RFC 7009). A login the
277/// server no longer knows counts as ended.
278pub 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}