1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
//! OAuth 2.0 / OIDC provider trait and core data types.
use std::fmt;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::{AuthError, Result};
/// User information retrieved from OAuth provider
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
/// Unique user identifier from provider
pub id: String,
/// User's email address.
///
/// `None` when the provider omits an email claim (e.g. a GitHub account with a
/// private email). An empty/whitespace-only claim is normalized to `None` so it
/// can never be used as an account-linking key — see the [`crate::account_linking`]
/// module, which keys email-less identities on `(provider, provider_id)` instead.
pub email: Option<String>,
/// Whether the provider asserts the email address is verified.
///
/// Cross-provider account linking requires this to be `true`; an unverified or
/// absent `email_verified` claim is treated as `false` (fail-closed).
pub email_verified: bool,
/// User's display name (optional)
pub name: Option<String>,
/// User's profile picture URL (optional)
pub picture: Option<String>,
/// Raw claims from provider (for custom fields)
pub raw_claims: serde_json::Value,
}
/// Token response from OAuth provider
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResponse {
/// Access token (short-lived)
pub access_token: String,
/// Refresh token if provider supports it
pub refresh_token: Option<String>,
/// Token expiration in seconds
pub expires_in: u64,
/// Token type (typically "Bearer")
pub token_type: String,
/// OIDC ID token, when the provider issued one.
///
/// `None` for plain-OAuth2 providers. It is load-bearing for providers that
/// publish no userinfo endpoint — Apple returns the whole identity here and
/// nowhere else — which is why [`OAuthProvider::user_info_from_tokens`]
/// exists.
pub id_token: Option<String>,
}
/// OAuth 2.0 / OIDC provider trait
///
/// Implement this trait to add support for custom OAuth providers.
// Reason: used as dyn Trait (Arc<dyn OAuthProvider>, Box<dyn OAuthProvider>); async_trait ensures
// Send bounds and dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is
// stable (RFC 3425)
#[async_trait]
pub trait OAuthProvider: Send + Sync + fmt::Debug {
/// Provider name for logging/debugging
fn name(&self) -> &str;
/// Generate authorization URL for user to visit
///
/// # Arguments
/// * `state` - CSRF protection state (should be cryptographically random)
fn authorization_url(&self, state: &str) -> String;
/// Exchange authorization code for tokens
///
/// # Arguments
/// * `code` - Authorization code from provider
///
/// # Returns
/// Token response with access_token and optional refresh_token
async fn exchange_code(&self, code: &str) -> Result<TokenResponse>;
/// Get user information using access token
///
/// # Arguments
/// * `access_token` - The access token to use for API call
///
/// # Returns
/// UserInfo with user details from provider
async fn user_info(&self, access_token: &str) -> Result<UserInfo>;
/// Get user information from a whole token response.
///
/// This is the method callers should use after
/// [`exchange_code`](Self::exchange_code): it defaults to
/// [`user_info`](Self::user_info) with the access token, which is right for
/// every provider that publishes a userinfo endpoint, and lets a provider
/// whose identity lives in the `id_token` — Apple has no userinfo endpoint
/// at all — override it.
///
/// A provider that overrides this should make its `user_info` fail loudly
/// rather than return a degraded identity, so a caller on the wrong method
/// gets an error and not a silently wrong subject.
///
/// # Errors
///
/// Whatever the provider's identity fetch returns.
async fn user_info_from_tokens(&self, tokens: &TokenResponse) -> Result<UserInfo> {
self.user_info(&tokens.access_token).await
}
/// Refresh the access token (optional, default returns error)
///
/// # Arguments
/// * `refresh_token` - The refresh token
///
/// # Returns
/// New TokenResponse if provider supports refresh
async fn refresh_token(&self, _refresh_token: &str) -> Result<TokenResponse> {
Err(AuthError::OAuthError {
message: format!("{} does not support token refresh", self.name()),
})
}
/// Revoke a token (optional, default is no-op)
///
/// # Arguments
/// * `token` - Token to revoke
async fn revoke_token(&self, _token: &str) -> Result<()> {
Ok(())
}
}
/// PKCE (Proof Key for Public Clients) helper
///
/// Used to prevent authorization code interception attacks
#[derive(Debug, Clone)]
pub struct PkceChallenge {
/// Generated code verifier (cryptographically random)
pub verifier: String,
/// Code challenge (SHA256 hash of verifier)
pub challenge: String,
}
impl PkceChallenge {
/// Generate a new PKCE challenge.
///
/// # Errors
///
/// Returns [`AuthError::PkceError`] if the generated verifier fails RFC 7636
/// length or character-set constraints (essentially never in practice).
pub fn generate() -> Result<Self> {
use sha2::{Digest, Sha256};
let verifier = generate_pkce_verifier()?;
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let challenge_bytes = hasher.finalize();
let challenge = base64_url_encode(&challenge_bytes);
Ok(Self {
verifier,
challenge,
})
}
/// Validate a verifier against a challenge.
///
/// Uses constant-time equality to prevent timing attacks on the PKCE challenge
/// (L-pkce-triplication: this path previously used `==`, drifting from the
/// constant-time `oauth::pkce::PkceChallenge::verify`).
#[must_use]
pub fn validate(&self, verifier: &str) -> bool {
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq as _;
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let hash = hasher.finalize();
let encoded = base64_url_encode(&hash);
encoded.as_bytes().ct_eq(self.challenge.as_bytes()).into()
}
}
/// Generate a PKCE verifier (43-128 characters of unreserved characters)
///
/// # SECURITY
///
/// This uses `rand::rng()` which is cryptographically secure on all major platforms.
/// It generates a 128-character random string using only unreserved characters as per RFC 7636.
///
/// The generated verifier meets these requirements:
/// - Length: exactly 128 characters (within 43-128 range)
/// - Characters: only unreserved ASCII characters: [A-Z a-z 0-9 - . _ ~]
/// - Randomness: cryptographically secure pseudorandom generation
/// - No padding: can be used directly in PKCE challenge
///
/// # Errors
///
/// Returns error if:
/// - Random number generation fails (extremely rare)
/// - Generated verifier is invalid (should never happen given the constraints)
///
/// # Implementation Notes
///
/// We use a fixed 128-character length (maximum allowed by RFC 7636) for:
/// 1. Maximum security: more entropy means harder to guess
/// 2. Consistency: predictable length for tests and monitoring
/// 3. Compatibility: all OAuth providers support 128-char verifiers
fn generate_pkce_verifier() -> Result<String> {
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
const VERIFIER_LENGTH: usize = 128; // Maximum allowed by RFC 7636
const MIN_VERIFIER_LENGTH: usize = 43; // Minimum allowed by RFC 7636
// SECURITY: rand::rng() is backed by OS-level entropy for PKCE verifiers.
let mut rng = rand::rng();
let verifier: String = (0..VERIFIER_LENGTH)
.map(|_| {
let idx = rng.random_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect();
// Validate the generated verifier meets RFC 7636 requirements
if verifier.len() < MIN_VERIFIER_LENGTH {
return Err(AuthError::PkceError {
message: format!(
"Generated PKCE verifier too short: {} < {} chars",
verifier.len(),
MIN_VERIFIER_LENGTH
),
});
}
if verifier.len() > 128 {
return Err(AuthError::PkceError {
message: format!("Generated PKCE verifier too long: {} > 128 chars", verifier.len()),
});
}
// Verify all characters are from the allowed charset
let allowed_chars: std::collections::HashSet<char> =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
.chars()
.collect();
for (i, c) in verifier.chars().enumerate() {
if !allowed_chars.contains(&c) {
return Err(AuthError::PkceError {
message: format!(
"Generated PKCE verifier contains invalid character '{}' at position {}",
c, i
),
});
}
}
Ok(verifier)
}
/// URL-safe base64 encoding for PKCE
pub(crate) fn base64_url_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}