Skip to main content

xurl/store/
types.rs

1//! Token and app type definitions for the multi-app credential store.
2//!
3//! These types form the on-disk YAML schema for `~/.xurl` and the in-memory
4//! shape that [`crate::store::TokenStore`] exposes to library consumers.
5//! `App` groups one set of X API client credentials with the user / bearer
6//! tokens authorized against them; `Token` is the polymorphic envelope that
7//! carries an `OAuth1`, `OAuth2`, or bearer payload alongside its
8//! discriminator.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14// ── Token types ──────────────────────────────────────────────────────
15
16/// `OAuth1` HMAC-SHA1 access-token bundle.
17///
18/// Carries the four secrets needed to sign an `OAuth1` request: the
19/// per-user access pair (`access_token` / `token_secret`) and the
20/// per-app consumer pair (`consumer_key` / `consumer_secret`).
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct OAuth1Token {
23    /// `OAuth1` user access token (the per-user secret).
24    pub access_token: String,
25    /// `OAuth1` user token secret, paired with [`Self::access_token`].
26    pub token_secret: String,
27    /// `OAuth1` consumer key (the per-app identifier).
28    pub consumer_key: String,
29    /// `OAuth1` consumer secret, paired with [`Self::consumer_key`].
30    pub consumer_secret: String,
31}
32
33/// `OAuth2` PKCE access + refresh token pair with expiration.
34///
35/// `expiration_time` is a Unix epoch second; the refresh path treats any
36/// `expiration_time <= now` as expired and POSTs the refresh-grant.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct OAuth2Token {
39    /// Bearer access token returned by the `OAuth2` token endpoint.
40    pub access_token: String,
41    /// Refresh token used to mint a new access token after expiry.
42    pub refresh_token: String,
43    /// Unix epoch second at which [`Self::access_token`] expires.
44    pub expiration_time: u64,
45}
46
47/// Token-type discriminator carried alongside [`Token`].
48///
49/// Serialised lowercase in YAML (`bearer` / `oauth2` / `oauth1`) so the
50/// on-disk store remains compatible with the upstream Go xurl format.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "lowercase")]
53pub enum TokenType {
54    /// App-only bearer-token authentication.
55    Bearer,
56    /// `OAuth2` PKCE user-authorized token.
57    Oauth2,
58    /// `OAuth1` HMAC-SHA1 user-authorized token.
59    Oauth1,
60}
61
62/// Polymorphic token envelope: a [`TokenType`] discriminator plus exactly
63/// one populated payload field.
64///
65/// Only the variant matching [`Self::token_type`] is populated; the other
66/// two are `None` and skipped on serialize. Callers select the right
67/// helper on [`crate::store::TokenStore`] (`get_oauth1_tokens`,
68/// `get_oauth2_token`, `get_bearer_token`) rather than reading these fields
69/// directly.
70#[allow(clippy::struct_field_names)]
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Token {
73    /// Discriminator for which payload field is populated.
74    #[serde(rename = "type")]
75    pub token_type: TokenType,
76    /// Raw bearer-token string when [`Self::token_type`] is
77    /// [`TokenType::Bearer`].
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub bearer: Option<String>,
80    /// `OAuth2` payload when [`Self::token_type`] is [`TokenType::Oauth2`].
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub oauth2: Option<OAuth2Token>,
83    /// `OAuth1` payload when [`Self::token_type`] is [`TokenType::Oauth1`].
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub oauth1: Option<OAuth1Token>,
86}
87
88// ── App ──────────────────────────────────────────────────────────────
89
90/// Credentials and tokens for a single registered X API application.
91///
92/// One `App` corresponds to one X API client (one `client_id` /
93/// `client_secret` pair); it holds every `OAuth2` user token authorized
94/// against that app keyed by username, plus optional `OAuth1` and bearer
95/// tokens. The token store carries an arbitrary number of apps and a
96/// `default_app` name; the `--app NAME` flag selects between them.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct App {
99    /// `OAuth2` client ID issued by X for this app.
100    pub client_id: String,
101    /// `OAuth2` client secret paired with [`Self::client_id`].
102    pub client_secret: String,
103    /// Default `OAuth2` username for this app. When non-empty, lookups
104    /// without an explicit username prefer this entry over arbitrary-first.
105    #[serde(default, skip_serializing_if = "String::is_empty")]
106    pub default_user: String,
107    /// Stored `OAuth2` redirect URI override; empty means "fall through to
108    /// `REDIRECT_URI` env or the built-in default".
109    #[serde(default, skip_serializing_if = "String::is_empty")]
110    pub redirect_uri: String,
111    /// `OAuth2` user tokens, keyed by username (the value returned by
112    /// `/2/users/me`, or the explicit name passed to `xr auth oauth2 NAME`).
113    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
114    pub oauth2_tokens: BTreeMap<String, Token>,
115    /// `OAuth1` token for this app, if any.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub oauth1_token: Option<Token>,
118    /// Bearer token for this app, if any.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub bearer_token: Option<Token>,
121    /// Salvage slot for an `OAuth2` token whose `/2/users/me` lookup failed
122    /// during exchange. Lets the token-exchange path persist the access
123    /// token rather than discarding it when the username cannot be
124    /// resolved.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub unnamed_oauth2_token: Option<Token>,
127}
128
129impl App {
130    pub(crate) fn new() -> Self {
131        Self {
132            client_id: String::new(),
133            client_secret: String::new(),
134            default_user: String::new(),
135            redirect_uri: String::new(),
136            oauth2_tokens: BTreeMap::new(),
137            oauth1_token: None,
138            bearer_token: None,
139            unnamed_oauth2_token: None,
140        }
141    }
142
143    pub(crate) fn with_credentials(client_id: &str, client_secret: &str) -> Self {
144        Self {
145            client_id: client_id.to_string(),
146            client_secret: client_secret.to_string(),
147            ..Self::new()
148        }
149    }
150
151    pub(crate) fn has_tokens(&self) -> bool {
152        !self.oauth2_tokens.is_empty()
153            || self.oauth1_token.is_some()
154            || self.bearer_token.is_some()
155            || self.unnamed_oauth2_token.is_some()
156    }
157}
158
159// ── On-disk YAML structure ───────────────────────────────────────────
160
161/// Serialised YAML layout of `~/.xurl`.
162#[derive(Debug, Serialize, Deserialize)]
163pub(crate) struct StoreFile {
164    pub apps: BTreeMap<String, App>,
165    pub default_app: String,
166}