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}