Skip to main content

ccwire_oauth/
token.rs

1//! The refresh grant's types.
2
3use alloc::borrow::ToOwned;
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::time::Duration;
7use std::time::SystemTime;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{Secret, lenient};
12
13/// Claude Code's public OAuth client id, the one its logins are issued to.
14/// A refresh must name the client the token was issued to.
15pub const CLAUDE_CODE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
16
17/// The scopes a refresh asks for when the caller names none: Claude Code's
18/// defaults for a subscription login.
19pub const DEFAULT_SCOPES: &[&str] = &[
20    "user:profile",
21    "user:inference",
22    "user:sessions:claude_code",
23    "user:mcp_servers",
24    "user:file_upload",
25    "user:plugins",
26];
27
28/// What to refresh: a refresh token, plus the client and scopes to post
29/// with it.
30///
31/// ```
32/// use ccwire_oauth::{RefreshRequest, Secret};
33///
34/// let req = RefreshRequest::new(Secret::new("stored-refresh-token"))
35///     .scopes(["user:profile", "user:inference"]);
36/// assert_eq!(req.scope_string(), "user:profile user:inference");
37/// ```
38#[derive(Debug, Clone)]
39pub struct RefreshRequest {
40    /// The refresh token.
41    pub refresh_token: Secret,
42    /// The OAuth client id. Default [`CLAUDE_CODE_CLIENT_ID`].
43    pub client_id: String,
44    /// The scopes to ask for. Default [`DEFAULT_SCOPES`]; pass the stored
45    /// login's scopes when it has them.
46    pub scopes: Vec<String>,
47}
48
49impl RefreshRequest {
50    /// A request for `refresh_token` with the default client and scopes.
51    pub fn new(refresh_token: Secret) -> Self {
52        Self {
53            refresh_token,
54            client_id: CLAUDE_CODE_CLIENT_ID.to_owned(),
55            scopes: DEFAULT_SCOPES.iter().map(|s| (*s).to_owned()).collect(),
56        }
57    }
58
59    /// Sets the client id.
60    #[must_use]
61    pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
62        self.client_id = client_id.into();
63        self
64    }
65
66    /// Sets the scopes. An empty list keeps the defaults.
67    #[must_use]
68    pub fn scopes<I, S>(mut self, scopes: I) -> Self
69    where
70        I: IntoIterator<Item = S>,
71        S: Into<String>,
72    {
73        let scopes: Vec<String> = scopes.into_iter().map(Into::into).collect();
74        if !scopes.is_empty() {
75            self.scopes = scopes;
76        }
77        self
78    }
79
80    /// The scopes as the token endpoint takes them: space-separated.
81    pub fn scope_string(&self) -> String {
82        self.scopes.join(" ")
83    }
84}
85
86/// The JSON body posted to the token endpoint.
87#[derive(Serialize)]
88pub(crate) struct GrantBody<'a> {
89    pub grant_type: &'static str,
90    pub refresh_token: &'a str,
91    pub client_id: &'a str,
92    pub scope: String,
93}
94
95/// The token endpoint's success response, read leniently.
96///
97/// The server has spent the posted refresh token by the time this is
98/// decoded, so only the tokens themselves are required: every other field
99/// reads as `None` when it is missing, `null` or of an unexpected type, and
100/// unknown fields are ignored. A body without a string `access_token` (or
101/// with a `refresh_token` that isn't a string or `null`) is the only decode
102/// failure.
103#[derive(Deserialize)]
104pub(crate) struct GrantResponse {
105    pub access_token: Secret,
106    #[serde(default)]
107    pub refresh_token: Option<Secret>,
108    #[serde(default, deserialize_with = "lenient::seconds")]
109    pub expires_in: Option<u64>,
110    #[serde(default, deserialize_with = "lenient::seconds")]
111    pub refresh_token_expires_in: Option<u64>,
112    #[serde(default, deserialize_with = "lenient::opt")]
113    pub scope: Option<String>,
114    #[serde(default, deserialize_with = "lenient::opt")]
115    pub token_type: Option<String>,
116}
117
118/// A refreshed login: what to store in place of the old tokens.
119///
120/// The refresh token rotates: the one posted is spent. Store
121/// [`TokenSet::refresh_token`] only if the stored one is still the one you
122/// posted (compare-and-swap); if another process already stored a newer
123/// pair, keep that one instead.
124#[derive(Debug, Clone)]
125#[non_exhaustive]
126pub struct TokenSet {
127    /// The new access token.
128    pub access_token: Secret,
129    /// The refresh token to store: the new one, or the posted one if the
130    /// response didn't rotate it (see [`TokenSet::rotated`]).
131    pub refresh_token: Secret,
132    /// Whether the response carried a new refresh token.
133    pub rotated: bool,
134    /// The access token's lifetime, as the response gave it. `None` if the
135    /// response had no readable `expires_in`: treat the token's expiry as
136    /// unknown (use it until a call is refused, then refresh).
137    pub expires_in: Option<Duration>,
138    /// When the access token expires: the response's time plus
139    /// [`TokenSet::expires_in`]. `None` when that is unknown or too far out
140    /// to represent.
141    pub expires_at: Option<SystemTime>,
142    /// When the new refresh token expires, if the response said.
143    pub refresh_token_expires_at: Option<SystemTime>,
144    /// The new refresh token's lifetime, as the response gave it
145    /// (`refresh_token_expires_in`), if it did.
146    pub refresh_token_expires_in: Option<Duration>,
147    /// The granted scopes (empty if the response didn't say).
148    pub scopes: Vec<String>,
149    /// The token type (`Bearer`), if the response said.
150    pub token_type: Option<String>,
151}
152
153impl TokenSet {
154    pub(crate) fn from_response(r: GrantResponse, posted: &Secret, now: SystemTime) -> Self {
155        let expires_in = r.expires_in.map(Duration::from_secs);
156        let refresh_token_expires_in = r.refresh_token_expires_in.map(Duration::from_secs);
157        let (refresh_token, rotated) = match r.refresh_token {
158            Some(t) if !t.is_empty() => (t, true),
159            _ => (posted.clone(), false),
160        };
161        Self {
162            access_token: r.access_token,
163            refresh_token,
164            rotated,
165            expires_in,
166            // Checked: a huge `expires_in` is `None`, never a panic.
167            expires_at: expires_in.and_then(|d| now.checked_add(d)),
168            refresh_token_expires_at: refresh_token_expires_in.and_then(|d| now.checked_add(d)),
169            refresh_token_expires_in,
170            scopes: r
171                .scope
172                .unwrap_or_default()
173                .split_whitespace()
174                .map(ToOwned::to_owned)
175                .collect(),
176            token_type: r.token_type,
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn decode(body: &str) -> TokenSet {
186        let r: GrantResponse = serde_json::from_str(body).unwrap();
187        TokenSet::from_response(r, &Secret::new("posted"), SystemTime::UNIX_EPOCH)
188    }
189
190    #[test]
191    fn huge_expiry_does_not_panic() {
192        let t = decode(
193            r#"{"access_token":"a","refresh_token":"r","expires_in":18446744073709551615,"refresh_token_expires_in":18446744073709551615}"#,
194        );
195        assert_eq!(t.expires_in, Some(Duration::from_secs(u64::MAX)));
196        assert_eq!(t.expires_at, None);
197        assert_eq!(t.refresh_token_expires_at, None);
198        assert_eq!(
199            t.refresh_token_expires_in,
200            Some(Duration::from_secs(u64::MAX))
201        );
202        assert_eq!(t.refresh_token.expose(), "r");
203    }
204
205    #[test]
206    fn odd_fields_keep_the_tokens() {
207        for body in [
208            r#"{"access_token":"a","refresh_token":"r"}"#,
209            r#"{"access_token":"a","refresh_token":"r","expires_in":"3600"}"#,
210            r#"{"access_token":"a","refresh_token":"r","expires_in":3600.0}"#,
211            r#"{"access_token":"a","refresh_token":"r","expires_in":{"s":1},"scope":7,"token_type":[]}"#,
212            r#"{"access_token":"a","refresh_token":"r","expires_in":-5,"refresh_token_expires_in":"soon"}"#,
213            r#"{"access_token":"a","refresh_token":"r","expires_in":null,"new_field":{"x":[1,2]}}"#,
214        ] {
215            let t = decode(body);
216            assert_eq!(t.access_token.expose(), "a", "{body}");
217            assert_eq!(t.refresh_token.expose(), "r", "{body}");
218            assert!(t.rotated, "{body}");
219        }
220        let t = decode(r#"{"access_token":"a","refresh_token":"r","expires_in":"3600"}"#);
221        assert_eq!(t.expires_in, Some(Duration::from_secs(3600)));
222        assert_eq!(
223            t.expires_at,
224            Some(SystemTime::UNIX_EPOCH + Duration::from_secs(3600))
225        );
226        let t = decode(r#"{"access_token":"a","refresh_token":"r"}"#);
227        assert_eq!((t.expires_in, t.expires_at), (None, None));
228        assert_eq!(
229            (t.refresh_token_expires_in, t.refresh_token_expires_at),
230            (None, None)
231        );
232    }
233}