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}