Skip to main content

bitwarden_access_token/
token.rs

1//! Parsing and opening an access token (the holder side).
2
3use std::fmt;
4
5use bitwarden_access_token_crypto::{AccessTokenError, AccessTokenKey, AccessTokenSeed};
6use bitwarden_crypto::{KeySlotIds, KeyStoreContext};
7use bitwarden_encoding::{B64, NotB64EncodedError};
8use bitwarden_sensitive_value::SensitiveString;
9use thiserror::Error;
10use uuid::Uuid;
11
12use crate::{AccessTokenKind, consts::TOKEN_VERSION};
13
14/// A parsed access token. The derived key only reaches a key store through
15/// [`Self::open_payload`].
16pub struct AccessToken {
17    kind: AccessTokenKind,
18    api_key_id: Uuid,
19    client_secret: SensitiveString,
20    key: AccessTokenKey,
21}
22
23impl fmt::Debug for AccessToken {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        f.debug_struct("AccessToken")
26            .field("kind", &self.kind)
27            .field("api_key_id", &self.api_key_id)
28            .finish_non_exhaustive()
29    }
30}
31
32/// Reasons an access token string could not be parsed.
33///
34/// Not `PartialEq`: the base64 variant wraps [`NotB64EncodedError`], which does not implement it.
35#[derive(Debug, Error)]
36pub enum AccessTokenInvalidError {
37    /// Missing `:`, or the wrong number of dot-segments for the expected [`AccessTokenKind`].
38    #[error("Has the wrong number of parts")]
39    WrongParts,
40    /// The version segment was not `0`.
41    #[error("Is the wrong version")]
42    WrongVersion,
43    /// The client-kind segment did not match the expected [`AccessTokenKind`].
44    #[error("Has the wrong prefix")]
45    WrongPrefix,
46    /// The API key id segment was not a UUID.
47    #[error("Has an invalid identifier")]
48    InvalidUuid,
49    /// The seed was not valid base64.
50    #[error("Error decoding base64: {0}")]
51    InvalidBase64(#[from] NotB64EncodedError),
52    /// The seed decoded to something other than 16 bytes.
53    #[error("Invalid base64 length: expected {expected}, got {got}")]
54    InvalidLength {
55        /// The length the format requires.
56        expected: usize,
57        /// The decoded length.
58        got: usize,
59    },
60}
61
62impl AccessToken {
63    /// Parses a token in the shape `expected` requires, including its client-kind segment.
64    pub fn parse(token: &str, expected: AccessTokenKind) -> Result<Self, AccessTokenInvalidError> {
65        let (prefix, seed_b64) = token
66            .split_once(':')
67            .ok_or(AccessTokenInvalidError::WrongParts)?;
68
69        let parts: Vec<&str> = prefix.split('.').collect();
70
71        let (version, client_kind, api_key_id, client_secret): (&str, Option<&str>, &str, &str) =
72            match expected.segment() {
73                Some(_) => {
74                    let [version, client_kind, api_key_id, client_secret]: [&str; 4] = parts
75                        .try_into()
76                        .map_err(|_| AccessTokenInvalidError::WrongParts)?;
77                    (version, Some(client_kind), api_key_id, client_secret)
78                }
79                None => {
80                    let [version, api_key_id, client_secret]: [&str; 3] = parts
81                        .try_into()
82                        .map_err(|_| AccessTokenInvalidError::WrongParts)?;
83                    (version, None, api_key_id, client_secret)
84                }
85            };
86
87        if version != TOKEN_VERSION {
88            return Err(AccessTokenInvalidError::WrongVersion);
89        }
90
91        if client_kind != expected.segment() {
92            return Err(AccessTokenInvalidError::WrongPrefix);
93        }
94
95        let api_key_id = api_key_id
96            .parse()
97            .map_err(|_| AccessTokenInvalidError::InvalidUuid)?;
98
99        let seed_b64: B64 = seed_b64.parse()?;
100        let seed = AccessTokenSeed::try_from(seed_b64.as_bytes()).map_err(|e| {
101            AccessTokenInvalidError::InvalidLength {
102                expected: e.expected,
103                got: e.got,
104            }
105        })?;
106
107        Ok(Self {
108            kind: expected,
109            api_key_id,
110            client_secret: SensitiveString::from(client_secret),
111            key: AccessTokenKey::derive(&seed, expected.key_purpose()),
112        })
113    }
114
115    /// The API key identifier. See [`Self::client_id`] for the full OAuth `client_id`.
116    pub fn api_key_id(&self) -> Uuid {
117        self.api_key_id
118    }
119
120    /// The OAuth client secret.
121    pub fn client_secret(&self) -> &SensitiveString {
122        &self.client_secret
123    }
124
125    /// Which [`AccessTokenKind`] this token authenticates.
126    pub fn kind(&self) -> AccessTokenKind {
127        self.kind
128    }
129
130    /// The OAuth `client_id`: `<kind>.<api_key_id>`, or the bare `<api_key_id>` for Secrets
131    /// Manager.
132    pub fn client_id(&self) -> String {
133        match self.kind.segment() {
134            Some(segment) => format!("{segment}.{}", self.api_key_id),
135            None => self.api_key_id.to_string(),
136        }
137    }
138
139    /// Decrypts `encrypted_payload` and installs the organization key it carries at
140    /// `organization_key` in `ctx`. Errors carry no payload content.
141    pub fn open_payload<Ids: KeySlotIds>(
142        &self,
143        ctx: &mut KeyStoreContext<Ids>,
144        encrypted_payload: &str,
145        organization_key: Ids::Symmetric,
146    ) -> Result<(), AccessTokenError> {
147        self.key
148            .open_payload(ctx, encrypted_payload, organization_key)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    /// The Secrets Manager test vector, in the four-part format.
157    const VALID_TOKEN: &str = "0.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
158
159    /// The same vector in Secrets Manager's three-part format.
160    const VALID_SM_TOKEN: &str = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
161
162    fn parse_valid() -> AccessToken {
163        AccessToken::parse(VALID_TOKEN, AccessTokenKind::AccessConnector)
164            .expect("valid token must parse")
165    }
166
167    /// Derived-key bytes are covered by `bitwarden-access-token-crypto`'s known-answer tests.
168    #[test]
169    fn valid_token_round_trip() {
170        let token = parse_valid();
171
172        assert_eq!(
173            token.api_key_id().to_string(),
174            "ec2c1d46-6a4b-4751-a310-af9601317f2d"
175        );
176        use bitwarden_sensitive_value::ExposeSensitive as _;
177        assert_eq!(
178            token.client_secret().expose(),
179            "C2IgxjjLF7qSshsbwe8JGcbM075YXw"
180        );
181    }
182
183    #[test]
184    fn client_id_format() {
185        let token = parse_valid();
186        assert_eq!(
187            token.client_id(),
188            "access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d"
189        );
190    }
191
192    #[test]
193    fn base64_without_padding_is_accepted() {
194        let t = "0.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ";
195        assert!(AccessToken::parse(t, AccessTokenKind::AccessConnector).is_ok());
196    }
197
198    #[test]
199    fn wrong_version_is_rejected() {
200        let t = "1.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
201        assert!(matches!(
202            AccessToken::parse(t, AccessTokenKind::AccessConnector),
203            Err(AccessTokenInvalidError::WrongVersion)
204        ));
205    }
206
207    #[test]
208    fn wrong_prefix_is_rejected() {
209        let t = "0.access.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
210        assert!(matches!(
211            AccessToken::parse(t, AccessTokenKind::AccessConnector),
212            Err(AccessTokenInvalidError::WrongPrefix)
213        ));
214    }
215
216    #[test]
217    fn missing_colon_gives_wrong_parts() {
218        // The key follows a dot instead of the colon.
219        let t = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw.X8vbvA0bduihIDe/qrzIQQ==";
220        assert!(matches!(
221            AccessToken::parse(t, AccessTokenKind::AccessConnector),
222            Err(AccessTokenInvalidError::WrongParts)
223        ));
224    }
225
226    #[test]
227    fn too_few_dot_parts_gives_wrong_parts() {
228        let t = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
229        assert!(matches!(
230            AccessToken::parse(t, AccessTokenKind::AccessConnector),
231            Err(AccessTokenInvalidError::WrongParts)
232        ));
233    }
234
235    #[test]
236    fn too_many_dot_parts_gives_wrong_parts() {
237        let t = "0.access-connector.extra.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
238        assert!(matches!(
239            AccessToken::parse(t, AccessTokenKind::AccessConnector),
240            Err(AccessTokenInvalidError::WrongParts)
241        ));
242    }
243
244    #[test]
245    fn invalid_uuid_is_rejected() {
246        let t =
247            "0.access-connector.not-a-uuid.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
248        assert!(matches!(
249            AccessToken::parse(t, AccessTokenKind::AccessConnector),
250            Err(AccessTokenInvalidError::InvalidUuid)
251        ));
252    }
253
254    #[test]
255    fn invalid_base64_is_rejected() {
256        let t = "0.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:!!!notbase64!!!";
257        assert!(matches!(
258            AccessToken::parse(t, AccessTokenKind::AccessConnector),
259            Err(AccessTokenInvalidError::InvalidBase64(_))
260        ));
261    }
262
263    #[test]
264    fn wrong_key_length_is_rejected() {
265        let short_key = B64::from([0u8; 15].as_slice()).to_string();
266        let t =
267            format!("0.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.secret:{short_key}");
268        assert!(matches!(
269            AccessToken::parse(&t, AccessTokenKind::AccessConnector),
270            Err(AccessTokenInvalidError::InvalidLength {
271                expected: 16,
272                got: 15
273            })
274        ));
275    }
276
277    #[test]
278    fn sm_token_matches_the_known_answer() {
279        let token = AccessToken::parse(VALID_SM_TOKEN, AccessTokenKind::SecretsManager)
280            .expect("valid SM token must parse");
281        assert_eq!(token.client_id(), "ec2c1d46-6a4b-4751-a310-af9601317f2d");
282        use bitwarden_sensitive_value::ExposeSensitive as _;
283        assert_eq!(
284            token.client_secret().expose(),
285            "C2IgxjjLF7qSshsbwe8JGcbM075YXw"
286        );
287    }
288
289    #[test]
290    fn sm_base64_without_padding_is_accepted() {
291        let t = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ";
292        assert!(AccessToken::parse(t, AccessTokenKind::SecretsManager).is_ok());
293    }
294
295    #[test]
296    fn sm_wrong_version_is_rejected() {
297        let t = "1.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
298        assert!(matches!(
299            AccessToken::parse(t, AccessTokenKind::SecretsManager),
300            Err(AccessTokenInvalidError::WrongVersion)
301        ));
302    }
303
304    /// No version segment: one part short of the Secrets Manager shape.
305    #[test]
306    fn sm_missing_version_segment_is_wrong_parts() {
307        let t = "ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
308        assert!(matches!(
309            AccessToken::parse(t, AccessTokenKind::SecretsManager),
310            Err(AccessTokenInvalidError::WrongParts)
311        ));
312    }
313
314    #[test]
315    fn access_connector_token_parsed_as_sm_is_wrong_parts() {
316        assert!(matches!(
317            AccessToken::parse(VALID_TOKEN, AccessTokenKind::SecretsManager),
318            Err(AccessTokenInvalidError::WrongParts)
319        ));
320    }
321
322    #[test]
323    fn sm_token_parsed_as_access_connector_is_wrong_parts() {
324        assert!(matches!(
325            AccessToken::parse(VALID_SM_TOKEN, AccessTokenKind::AccessConnector),
326            Err(AccessTokenInvalidError::WrongParts)
327        ));
328    }
329
330    #[test]
331    fn debug_output_contains_no_secret_material() {
332        let token = parse_valid();
333        let debug_str = format!("{token:?}");
334
335        assert!(
336            !debug_str.contains("C2IgxjjLF7qSshsbwe8JGcbM075YXw"),
337            "debug output leaked client_secret: {debug_str}"
338        );
339        assert!(
340            !debug_str.contains("X8vbvA0bduihIDe"),
341            "debug output leaked key bytes: {debug_str}"
342        );
343        assert!(debug_str.contains("AccessToken"));
344        assert!(debug_str.contains("ec2c1d46-6a4b-4751-a310-af9601317f2d"));
345    }
346}