Skip to main content

vault_core/
oauth.rs

1//! OAuth refresh-token grants, shared by `unv oauth refresh` and the desktop
2//! app's "Refresh access token" (Phase 24.5). Pure over JSON: the HTTP call is
3//! the caller's (the CLI's blocking client, the app's pinned `remote_request`),
4//! so there is exactly one reading of what an issuer's answer means and one rule
5//! for what gets stored.
6
7use crate::type_emit::var;
8use serde_json::{json, Value};
9
10pub fn set_var(entry: &mut Value, key: &str, value: &str, secret: bool) {
11    let vars = entry
12        .as_object_mut()
13        .expect("entry is an object")
14        .entry("extra_vars")
15        .or_insert_with(|| json!([]));
16    let vars = vars.as_array_mut().expect("extra_vars is an array");
17    match vars
18        .iter_mut()
19        .find(|v| v.get("key").and_then(Value::as_str) == Some(key))
20    {
21        Some(slot) => slot["value"] = json!(value),
22        None => vars.push(json!({ "key": key, "value": value, "secret": secret })),
23    }
24}
25
26/// The request an `oauth_client` entry makes to refresh itself: the `token_url`
27/// and the form fields. Refuses plain http except to localhost, and an entry with
28/// no refresh token, before anything is sent.
29pub fn refresh_request(entry: &Value) -> Result<(String, Vec<(String, String)>), String> {
30    let url = var(entry, "token_url").to_string();
31    if !(url.starts_with("https://")
32        || url.starts_with("http://127.0.0.1")
33        || url.starts_with("http://localhost"))
34    {
35        return Err("token_url must be https:// (plain http is allowed for localhost only)".into());
36    }
37    let refresh = var(entry, "refresh_token");
38    if refresh.is_empty() {
39        return Err("the entry holds no refresh_token".into());
40    }
41    let mut form = vec![
42        ("grant_type".to_string(), "refresh_token".to_string()),
43        ("refresh_token".to_string(), refresh.to_string()),
44        ("client_id".to_string(), var(entry, "client_id").to_string()),
45    ];
46    for (field, name) in [("client_secret", "client_secret"), ("scopes", "scope")] {
47        let v = var(entry, field);
48        if !v.is_empty() {
49            form.push((name.to_string(), v.to_string()));
50        }
51    }
52    Ok((url, form))
53}
54
55/// The host a refresh would contact, for the confirmation prompt.
56pub fn refresh_host(url: &str) -> &str {
57    url.split("://")
58        .nth(1)
59        .and_then(|r| r.split('/').next())
60        .unwrap_or("")
61}
62
63/// What the token endpoint said, reduced to what is stored.
64#[derive(Debug, PartialEq)]
65pub struct Grant {
66    pub access_token: String,
67    pub refresh_token: Option<String>,
68    pub expires_in: Option<i64>,
69}
70
71pub fn parse_grant(body: &Value) -> Result<Grant, String> {
72    if let Some(err) = body.get("error").and_then(Value::as_str) {
73        // Names the issuer's error code, never echoes the response body.
74        return Err(format!("the issuer refused the refresh: {err}"));
75    }
76    let access = body
77        .get("access_token")
78        .and_then(Value::as_str)
79        .filter(|s| !s.is_empty())
80        .ok_or("the response has no access_token")?;
81    Ok(Grant {
82        access_token: access.to_string(),
83        refresh_token: body
84            .get("refresh_token")
85            .and_then(Value::as_str)
86            .filter(|s| !s.is_empty())
87            .map(String::from),
88        expires_in: body.get("expires_in").and_then(Value::as_i64),
89    })
90}
91
92/// Stores a grant on the entry. Returns whether the refresh token rotated.
93pub fn apply_grant(entry: &mut Value, g: &Grant, now_unix: i64) -> bool {
94    let rotated = g
95        .refresh_token
96        .as_deref()
97        .is_some_and(|r| r != var(entry, "refresh_token"));
98    if let Some(r) = &g.refresh_token {
99        set_var(entry, "refresh_token", r, true);
100    }
101    set_var(entry, "access_token", &g.access_token, true);
102    if let Some(secs) = g.expires_in {
103        let at = crate::pool::iso_at(now_unix + secs);
104        set_var(entry, "access_expires_at", &at, false);
105    }
106    rotated
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::type_emit::var;
113
114    fn entry() -> Value {
115        json!({"secretType":"oauth_client","provider":"Slack","extra_vars":[
116            {"key":"refresh_token","value":"old-r","secret":true}]})
117    }
118
119    #[test]
120    fn a_rotated_refresh_token_replaces_the_old_one_and_is_reported() {
121        let g =
122            parse_grant(&json!({"access_token":"a1","refresh_token":"new-r","expires_in":3600}))
123                .unwrap();
124        let mut e = entry();
125        assert!(apply_grant(&mut e, &g, 1_700_000_000));
126        assert_eq!(var(&e, "refresh_token"), "new-r");
127        assert_eq!(var(&e, "access_token"), "a1");
128        assert_eq!(var(&e, "access_expires_at"), "2023-11-14T23:13:20Z");
129    }
130
131    #[test]
132    fn an_unrotated_response_keeps_the_stored_refresh_token() {
133        let g = parse_grant(&json!({"access_token":"a2"})).unwrap();
134        let mut e = entry();
135        assert!(!apply_grant(&mut e, &g, 0));
136        assert_eq!(var(&e, "refresh_token"), "old-r");
137        assert_eq!(var(&e, "access_expires_at"), "");
138    }
139
140    #[test]
141    fn issuer_errors_are_named_without_echoing_the_body() {
142        let err = parse_grant(
143            &json!({"error":"invalid_grant","error_description":"secret-bearing text"}),
144        )
145        .unwrap_err();
146        assert!(err.contains("invalid_grant") && !err.contains("secret-bearing"));
147        assert!(parse_grant(&json!({})).is_err());
148    }
149}