1use 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
14pub 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#[derive(Debug, Error)]
36pub enum AccessTokenInvalidError {
37 #[error("Has the wrong number of parts")]
39 WrongParts,
40 #[error("Is the wrong version")]
42 WrongVersion,
43 #[error("Has the wrong prefix")]
45 WrongPrefix,
46 #[error("Has an invalid identifier")]
48 InvalidUuid,
49 #[error("Error decoding base64: {0}")]
51 InvalidBase64(#[from] NotB64EncodedError),
52 #[error("Invalid base64 length: expected {expected}, got {got}")]
54 InvalidLength {
55 expected: usize,
57 got: usize,
59 },
60}
61
62impl AccessToken {
63 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 pub fn api_key_id(&self) -> Uuid {
117 self.api_key_id
118 }
119
120 pub fn client_secret(&self) -> &SensitiveString {
122 &self.client_secret
123 }
124
125 pub fn kind(&self) -> AccessTokenKind {
127 self.kind
128 }
129
130 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 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 const VALID_TOKEN: &str = "0.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
158
159 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 #[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 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 #[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}