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
6use crate::jwt::{MAX_CLOCK_SKEW_SECS, MAX_TOKEN_AGE_SECS};
7
8/// OAuth2 token response from provider
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct TokenResponse {
11    /// Access token for API calls
12    pub access_token:  String,
13    /// Refresh token for getting new access tokens
14    pub refresh_token: Option<String>,
15    /// Token type (typically "Bearer")
16    pub token_type:    String,
17    /// Seconds until access token expires
18    pub expires_in:    u64,
19    /// ID token (JWT) for OIDC
20    pub id_token:      Option<String>,
21    /// Requested scopes
22    pub scope:         Option<String>,
23}
24
25impl TokenResponse {
26    /// Create new token response
27    #[must_use]
28    pub const fn new(access_token: String, token_type: String, expires_in: u64) -> Self {
29        Self {
30            access_token,
31            refresh_token: None,
32            token_type,
33            expires_in,
34            id_token: None,
35            scope: None,
36        }
37    }
38
39    /// Calculate expiry time
40    #[must_use]
41    pub fn expiry_time(&self) -> DateTime<Utc> {
42        Utc::now() + Duration::seconds(self.expires_in.cast_signed())
43    }
44
45    /// Check if token is expired
46    #[must_use]
47    pub fn is_expired(&self) -> bool {
48        self.expiry_time() <= Utc::now()
49    }
50}
51
52/// JWT ID token claims
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct IdTokenClaims {
55    /// Issuer (provider identifier)
56    pub iss:            String,
57    /// Subject (unique user ID)
58    pub sub:            String,
59    /// Audience (should be client_id)
60    pub aud:            String,
61    /// Expiration time (Unix timestamp)
62    pub exp:            i64,
63    /// Issued at time (Unix timestamp)
64    pub iat:            i64,
65    /// Not-before time (Unix timestamp) — optional per RFC 7519 §4.1.5.
66    ///
67    /// When present, the token MUST NOT be accepted before this time (plus
68    /// [`MAX_CLOCK_SKEW_SECS`]).  When absent, the not-before check is skipped.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub nbf:            Option<i64>,
71    /// Authentication time (Unix timestamp)
72    pub auth_time:      Option<i64>,
73    /// Nonce (for replay protection)
74    pub nonce:          Option<String>,
75    /// Email address
76    pub email:          Option<String>,
77    /// Email verified flag
78    pub email_verified: Option<bool>,
79    /// User name
80    pub name:           Option<String>,
81    /// Profile picture URL
82    pub picture:        Option<String>,
83    /// Locale
84    pub locale:         Option<String>,
85}
86
87impl IdTokenClaims {
88    /// Create new ID token claims
89    #[must_use]
90    pub const fn new(iss: String, sub: String, aud: String, exp: i64, iat: i64) -> Self {
91        Self {
92            iss,
93            sub,
94            aud,
95            exp,
96            iat,
97            nbf: None,
98            auth_time: None,
99            nonce: None,
100            email: None,
101            email_verified: None,
102            name: None,
103            picture: None,
104            locale: None,
105        }
106    }
107
108    /// Validate temporal claims: `iat` staleness/skew and `nbf` not-before.
109    ///
110    /// Enforces the same three guards as [`crate::jwt::Claims::validate_temporal_claims`]:
111    ///
112    /// - `iat` must not be more than [`MAX_CLOCK_SKEW_SECS`] seconds in the future.
113    /// - `iat` must not be more than [`MAX_TOKEN_AGE_SECS`] seconds in the past.
114    /// - `nbf` (if present) must not be more than [`MAX_CLOCK_SKEW_SECS`] seconds in the future
115    ///   (RFC 7519 §4.1.5).
116    ///
117    /// # Errors
118    ///
119    /// Returns a `String` describing the validation failure, compatible with
120    /// [`crate::oauth::client::OIDCClient::verify_id_token`]'s error return type.
121    pub fn validate_temporal_claims(&self) -> std::result::Result<(), String> {
122        let now = Utc::now().timestamp();
123        let max_skew = i64::try_from(MAX_CLOCK_SKEW_SECS).unwrap_or(300);
124        let max_age = i64::try_from(MAX_TOKEN_AGE_SECS).unwrap_or(86_400);
125
126        // iat: must not be substantially in the future (forgery / clock-skew guard).
127        if self.iat > now.saturating_add(max_skew) {
128            return Err(
129                "iat claim is too far in the future — possible forgery or clock skew".to_string()
130            );
131        }
132
133        // iat: must not be older than MAX_TOKEN_AGE_SECS (replay guard).
134        if now.saturating_sub(self.iat) > max_age {
135            return Err("iat claim indicates token is too old (possible replay)".to_string());
136        }
137
138        // nbf: not-before — token must not be used before the claim (with clock skew).
139        if let Some(nbf) = self.nbf {
140            if nbf > now.saturating_add(max_skew) {
141                return Err("token is not yet valid (nbf claim is in the future)".to_string());
142            }
143        }
144
145        Ok(())
146    }
147
148    /// Check if token is expired
149    #[must_use]
150    pub fn is_expired(&self) -> bool {
151        self.exp <= Utc::now().timestamp()
152    }
153
154    /// Check if token will be expired within grace period
155    #[must_use]
156    pub fn is_expiring_soon(&self, grace_seconds: i64) -> bool {
157        self.exp <= (Utc::now().timestamp() + grace_seconds)
158    }
159}
160
161/// Userinfo response from provider
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct UserInfo {
164    /// Subject (unique user ID)
165    pub sub:            String,
166    /// Email address
167    pub email:          Option<String>,
168    /// Email verified flag
169    pub email_verified: Option<bool>,
170    /// User name
171    pub name:           Option<String>,
172    /// Profile picture URL
173    pub picture:        Option<String>,
174    /// Locale
175    pub locale:         Option<String>,
176}
177
178impl UserInfo {
179    /// Create new userinfo
180    #[must_use]
181    pub const fn new(sub: String) -> Self {
182        Self {
183            sub,
184            email: None,
185            email_verified: None,
186            name: None,
187            picture: None,
188            locale: None,
189        }
190    }
191}