Skip to main content

bitwarden_core/auth/
jwt_token.rs

1use std::str::FromStr;
2
3use bitwarden_encoding::{B64Url, NotB64UrlEncodedError};
4use thiserror::Error;
5
6/// A Bitwarden secrets manager JWT Token.
7///
8/// References:
9/// - <https://github.com/bitwarden/server/blob/419760623a7af5f5d0cbdfeb2a7aba8c3608d880/src/Identity/IdentityServer/ClientStore.cs#L125-L126>
10/// - <https://github.com/bitwarden/server/blob/419760623a7af5f5d0cbdfeb2a7aba8c3608d880/src/Identity/IdentityServer/ClientStore.cs#L133>
11///
12/// TODO: We need to expand this to support user based JWT tokens.
13#[derive(serde::Deserialize)]
14pub struct JwtToken {
15    /// Expiration Time.
16    pub exp: u64,
17    /// Subject.
18    pub sub: String,
19    /// User's email.
20    pub email: Option<String>,
21    /// Used by Service Accounts to denote the organization.
22    pub organization: Option<String>,
23    /// Whether the user has an active premium membership. Present on user access-token JWTs;
24    /// absent on Service Account tokens (hence `Option`).
25    pub premium: Option<bool>,
26    /// The scopes the token has access to.
27    pub scope: Vec<String>,
28}
29
30/// Error when parsing JWT tokens.
31#[allow(missing_docs)]
32#[derive(Debug, Error)]
33pub enum JwtTokenParseError {
34    #[error("JWT token parse error: {0}")]
35    Parse(#[from] serde_json::Error),
36    #[error("JWT token decode error: {0}")]
37    Decode(#[from] NotB64UrlEncodedError),
38
39    #[error("JWT token has an invalid number of parts")]
40    InvalidParts,
41}
42
43impl FromStr for JwtToken {
44    type Err = JwtTokenParseError;
45
46    /// Parses a JWT token from a string.
47    ///
48    /// **Note:** This function does not validate the token signature.
49    fn from_str(s: &str) -> Result<Self, Self::Err> {
50        let split = s.split('.').collect::<Vec<_>>();
51        if split.len() != 3 {
52            return Err(Self::Err::InvalidParts);
53        }
54        let decoded = B64Url::try_from(split[1])?;
55        Ok(serde_json::from_slice(decoded.as_bytes())?)
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use crate::auth::jwt_token::JwtToken;
62
63    #[test]
64    fn can_decode_jwt() {
65        let jwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMwMURENkE1MEU4NEUxRDA5MUM4MUQzQjAwQkY5MDEwQz\
66        g1REJEOUFSUzI1NiIsInR5cCI6ImF0K2p3dCIsIng1dCI6Ik1CM1dwUTZFNGRDUnlCMDdBTC1RRU1oZHZabyJ9.eyJu\
67        YmYiOjE2NzUxMDM1NzcsImV4cCI6MTY3NTEwNzE3NywiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsImNsaWVudF9pZCI\
68        6IndlYiIsInN1YiI6ImUyNWQzN2YzLWI2MDMtNDBkZS04NGJhLWFmOTYwMTJmNWE0MiIsImF1dGhfdGltZSI6MTY3NT\
69        EwMzU0OSwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoidGVzdEBiaXR3YXJkZW4uY29tI\
70        iwiZW1haWxfdmVyaWZpZWQiOnRydWUsInNzdGFtcCI6IkUzNElDWVhRUFRDS01EVldBREZDNktHNDJCQldJRDdJIiwi\
71        bmFtZSI6IlRlc3QiLCJvcmdvd25lciI6ImY0ZTQ0YTdmLTExOTAtNDMyYS05ZDRhLWFmOTYwMTMxMjdjYiIsImRldml\
72        jZSI6Ijg5Mjg5M2FiLWRkNDMtNDUwYS04NGI1LWFhOWM1YjdiYjJkOCIsImp0aSI6IkEzMkVFNjY5NDdEQzlDNUE2MT\
73        IwRURBRTIwNzc5OUJFIiwiaWF0IjoxNjc1MTAzNTc3LCJzY29wZSI6WyJhcGkiLCJvZmZsaW5lX2FjY2VzcyJdLCJhb\
74        XIiOlsiQXBwbGljYXRpb24iXX0.AyDkKvjmyaSPQViQSa2sGTKIkDGrUAtDmwpE57K4DDWT0QvwDe7FMktmwiF4LH36\
75        wx_FnpH21VI1pzwJeTHXtaz3niANJtQZjzGFsNAna_95vrsxZC2YizgGlt6mX4YIGmAw9DiYrmaN0BvQOEm_caV_u6f\
76        a30iz9Kvjxf7cpzeZvPEysxGpB3k3TRYTkFUdV43HiXdhXMBhyyOpFU6Fk6yA41y7-8bGYc5mYGknWktmPD9Yx-1xKL\
77        ftFja1SnCoLPWvDeK60lqWZQiT4tZHCYJ7m0bBNCccYHc2Kk2Bo5-UoyDxazPwsqMxeNfjlaUuj3o5N_uQ-4n_gVbeA\
78        qWV2wrel5UhYjWnczMSLBtt9p0W35kkBPt3ZAnRWMtQMPNH04p-_L6cG-Xu6lDksBTwaavcmtnCKG8V91826EiQ8MrF\
79        wGWQRZV6tPKTDAYCgSAZGBY3QDmPGT5BeFcg5Ag_nYYIIifKP-kv10v_N-TOcT3NeGBOUlAZ-9m7iT7Rk3vC--SDZdA\
80        U5turoBFiiPL2XXfAjM7P0r7J91gfXc0FaD6I2jDxOmym5h7Yn5phLsbC2NlIXkZp54dKHICenPl4ve6ndDIJacVeS5\
81        f3LEddAPV8cAFza4DjA8pZJLFrMyRvMXcL_PjKF8qPVzqVWh03lfJ4clOIxR2gOuWIc902Y5E";
82
83        let token: JwtToken = jwt.parse().unwrap();
84        assert_eq!(token.exp, 1675107177);
85        assert_eq!(token.sub, "e25d37f3-b603-40de-84ba-af96012f5a42");
86        assert_eq!(token.email.as_deref(), Some("[email protected]"));
87        assert_eq!(token.organization.as_deref(), None);
88        assert_eq!(token.premium, Some(false));
89        assert_eq!(token.scope[0], "api");
90        assert_eq!(token.scope[1], "offline_access");
91    }
92
93    /// A user access-token JWT with `"premium":true` must parse the claim as `Some(true)`. The
94    /// payload below decodes to
95    /// `{"sub":"...","email":"[email protected]","premium":true,"scope":["api"],"exp":
96    /// 1675107177}`.
97    #[test]
98    fn can_decode_premium_claim() {
99        // header.payload.signature — only the payload segment is parsed (the signature is not
100        // verified), so a placeholder header/signature is sufficient for this test.
101        let payload = bitwarden_encoding::B64Url::from(
102            br#"{"sub":"e25d37f3-b603-40de-84ba-af96012f5a42","email":"[email protected]","premium":true,"scope":["api"],"exp":1675107177}"#
103                .as_slice(),
104        );
105        let jwt = format!("aaaa.{payload}.bbbb");
106
107        let token: JwtToken = jwt.parse().unwrap();
108        assert_eq!(token.premium, Some(true));
109        assert_eq!(token.email.as_deref(), Some("[email protected]"));
110    }
111
112    /// A Service Account token has no `premium` claim; the field must decode to `None` rather than
113    /// failing the parse.
114    #[test]
115    fn premium_absent_decodes_to_none() {
116        let payload = bitwarden_encoding::B64Url::from(
117            br#"{"sub":"e25d37f3-b603-40de-84ba-af96012f5a42","scope":["api"],"exp":1675107177}"#
118                .as_slice(),
119        );
120        let jwt = format!("aaaa.{payload}.bbbb");
121
122        let token: JwtToken = jwt.parse().unwrap();
123        assert_eq!(token.premium, None);
124    }
125}