Skip to main content

fraiseql_auth/oauth/
types.rs

1//! OAuth2 token and user information types.
2
3use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5
6/// OAuth2 token response from provider
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct TokenResponse {
9    /// Access token for API calls
10    pub access_token:  String,
11    /// Refresh token for getting new access tokens
12    pub refresh_token: Option<String>,
13    /// Token type (typically "Bearer")
14    pub token_type:    String,
15    /// Seconds until access token expires
16    pub expires_in:    u64,
17    /// ID token (JWT) for OIDC
18    pub id_token:      Option<String>,
19    /// Requested scopes
20    pub scope:         Option<String>,
21}
22
23impl TokenResponse {
24    /// Create new token response
25    pub const fn new(access_token: String, token_type: String, expires_in: u64) -> Self {
26        Self {
27            access_token,
28            refresh_token: None,
29            token_type,
30            expires_in,
31            id_token: None,
32            scope: None,
33        }
34    }
35
36    /// Calculate expiry time
37    pub fn expiry_time(&self) -> DateTime<Utc> {
38        Utc::now() + Duration::seconds(self.expires_in.cast_signed())
39    }
40
41    /// Check if token is expired
42    pub fn is_expired(&self) -> bool {
43        self.expiry_time() <= Utc::now()
44    }
45}
46
47/// JWT ID token claims
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct IdTokenClaims {
50    /// Issuer (provider identifier)
51    pub iss:            String,
52    /// Subject (unique user ID)
53    pub sub:            String,
54    /// Audience (should be client_id)
55    pub aud:            String,
56    /// Expiration time (Unix timestamp)
57    pub exp:            i64,
58    /// Issued at time (Unix timestamp)
59    pub iat:            i64,
60    /// Authentication time (Unix timestamp)
61    pub auth_time:      Option<i64>,
62    /// Nonce (for replay protection)
63    pub nonce:          Option<String>,
64    /// Email address
65    pub email:          Option<String>,
66    /// Email verified flag
67    pub email_verified: Option<bool>,
68    /// User name
69    pub name:           Option<String>,
70    /// Profile picture URL
71    pub picture:        Option<String>,
72    /// Locale
73    pub locale:         Option<String>,
74}
75
76impl IdTokenClaims {
77    /// Create new ID token claims
78    pub const fn new(iss: String, sub: String, aud: String, exp: i64, iat: i64) -> Self {
79        Self {
80            iss,
81            sub,
82            aud,
83            exp,
84            iat,
85            auth_time: None,
86            nonce: None,
87            email: None,
88            email_verified: None,
89            name: None,
90            picture: None,
91            locale: None,
92        }
93    }
94
95    /// Check if token is expired
96    pub fn is_expired(&self) -> bool {
97        self.exp <= Utc::now().timestamp()
98    }
99
100    /// Check if token will be expired within grace period
101    pub fn is_expiring_soon(&self, grace_seconds: i64) -> bool {
102        self.exp <= (Utc::now().timestamp() + grace_seconds)
103    }
104}
105
106/// Userinfo response from provider
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct UserInfo {
109    /// Subject (unique user ID)
110    pub sub:            String,
111    /// Email address
112    pub email:          Option<String>,
113    /// Email verified flag
114    pub email_verified: Option<bool>,
115    /// User name
116    pub name:           Option<String>,
117    /// Profile picture URL
118    pub picture:        Option<String>,
119    /// Locale
120    pub locale:         Option<String>,
121}
122
123impl UserInfo {
124    /// Create new userinfo
125    pub const fn new(sub: String) -> Self {
126        Self {
127            sub,
128            email: None,
129            email_verified: None,
130            name: None,
131            picture: None,
132            locale: None,
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    // --- TokenResponse tests ---
142
143    #[test]
144    fn test_token_response_deserializes_from_json() {
145        let json = r#"{
146            "access_token": "eyJhbGciOiJSUzI1NiJ9.test.sig",
147            "token_type": "Bearer",
148            "expires_in": 3600,
149            "refresh_token": "rt-abc123",
150            "scope": "openid profile email"
151        }"#;
152
153        let token: TokenResponse = serde_json::from_str(json)
154            .expect("valid OAuth token response JSON must deserialize successfully");
155
156        assert_eq!(token.access_token, "eyJhbGciOiJSUzI1NiJ9.test.sig");
157        assert_eq!(token.token_type, "Bearer");
158        assert_eq!(token.expires_in, 3600);
159        assert_eq!(token.refresh_token, Some("rt-abc123".to_string()));
160        assert_eq!(token.scope, Some("openid profile email".to_string()));
161    }
162
163    #[test]
164    fn test_token_response_missing_optional_fields() {
165        let json = r#"{
166            "access_token": "at_minimal",
167            "token_type": "Bearer",
168            "expires_in": 3600
169        }"#;
170
171        let token: TokenResponse = serde_json::from_str(json)
172            .expect("token response without optional fields must still deserialize");
173
174        assert!(token.refresh_token.is_none(), "missing refresh_token must deserialize to None");
175        assert!(token.id_token.is_none(), "missing id_token must deserialize to None");
176        assert!(token.scope.is_none(), "missing scope must deserialize to None");
177    }
178
179    #[test]
180    fn test_token_response_missing_access_token_fails() {
181        let json = r#"{
182            "token_type": "Bearer",
183            "expires_in": 3600
184        }"#;
185
186        let result: Result<TokenResponse, _> = serde_json::from_str(json);
187        assert!(result.is_err(), "token response without access_token must fail to deserialize");
188    }
189
190    #[test]
191    fn test_token_response_expiry_is_in_future() {
192        let token = TokenResponse::new("at".to_string(), "Bearer".to_string(), 3600);
193        let expiry = token.expiry_time();
194        assert!(
195            expiry > Utc::now(),
196            "expiry_time for a token with expires_in=3600 must be in the future"
197        );
198    }
199
200    #[test]
201    fn test_token_response_new_is_not_expired() {
202        let token = TokenResponse::new("at".to_string(), "Bearer".to_string(), 3600);
203        assert!(
204            !token.is_expired(),
205            "a freshly created token with expires_in=3600 must not be expired"
206        );
207    }
208
209    // --- IdTokenClaims tests ---
210
211    #[test]
212    fn test_id_token_claims_not_expired() {
213        let exp = (Utc::now() + chrono::Duration::hours(1)).timestamp();
214        let claims = IdTokenClaims::new(
215            "https://issuer.example.com".to_string(),
216            "user123".to_string(),
217            "client_id".to_string(),
218            exp,
219            Utc::now().timestamp(),
220        );
221        assert!(!claims.is_expired(), "future exp must not be expired");
222    }
223
224    #[test]
225    fn test_id_token_claims_expired() {
226        let exp = (Utc::now() - chrono::Duration::hours(1)).timestamp();
227        let claims = IdTokenClaims::new(
228            "https://issuer.example.com".to_string(),
229            "user123".to_string(),
230            "client_id".to_string(),
231            exp,
232            Utc::now().timestamp(),
233        );
234        assert!(claims.is_expired(), "past exp must be expired");
235    }
236
237    #[test]
238    fn test_id_token_claims_expiring_soon() {
239        let exp = (Utc::now() + chrono::Duration::seconds(30)).timestamp();
240        let claims = IdTokenClaims::new(
241            "https://issuer.example.com".to_string(),
242            "user123".to_string(),
243            "client_id".to_string(),
244            exp,
245            Utc::now().timestamp(),
246        );
247        assert!(
248            claims.is_expiring_soon(60),
249            "token expiring in 30s must be considered expiring soon with grace=60s"
250        );
251        assert!(
252            !claims.is_expiring_soon(10),
253            "token expiring in 30s must not be considered expiring soon with grace=10s"
254        );
255    }
256
257    // --- UserInfo tests ---
258
259    #[test]
260    fn test_userinfo_creation() {
261        let user = UserInfo::new("sub_123".to_string());
262        assert_eq!(user.sub, "sub_123");
263        assert!(user.email.is_none());
264        assert!(user.name.is_none());
265    }
266
267    #[test]
268    fn test_userinfo_deserializes_from_json() {
269        let json = r#"{
270            "sub": "user_789",
271            "email": "user@example.com",
272            "email_verified": true,
273            "name": "Test User"
274        }"#;
275        let user: UserInfo =
276            serde_json::from_str(json).expect("valid userinfo JSON must deserialize");
277        assert_eq!(user.sub, "user_789");
278        assert_eq!(user.email, Some("user@example.com".to_string()));
279        assert_eq!(user.email_verified, Some(true));
280    }
281}