1use async_trait::async_trait;
8use reqwest::header;
9use serde::Deserialize;
10
11use crate::driver::{
12 ExternalAuthAuthorizationStart, ExternalAuthCallback, ExternalAuthProfile,
13 ExternalAuthProviderConfig, ExternalAuthProviderDescriptor, ExternalAuthProviderDriver,
14 ExternalAuthProviderTestCheck, ExternalAuthProviderTestResult,
15};
16use crate::types::{ExternalAuthProtocol, ExternalAuthProviderKind};
17use crate::{ExternalAuthError, MapExternalAuthErr, Result};
18
19use super::oauth2::{
20 OAuth2ProviderDriver, oauth2_endpoint_error, oauth2_http_client, validate_url,
21};
22
23const QQ_NAMESPACE_PREFIX: &str = "qq:";
24const QQ_AUTHORIZATION_URL: &str = "https://graph.qq.com/oauth2.0/authorize";
25const QQ_TOKEN_URL: &str = "https://graph.qq.com/oauth2.0/token";
26const QQ_OPENID_URL: &str = "https://graph.qq.com/oauth2.0/me";
27const QQ_USERINFO_URL: &str = "https://graph.qq.com/user/get_user_info";
28const QQ_DEFAULT_SCOPES: &str = "get_user_info";
29const QQ_OPENID_MAX_LEN: usize = 255;
30const QQ_SNAPSHOT_MAX_LEN: usize = 255;
31
32#[derive(Default)]
34pub struct QqProviderDriver;
35
36#[derive(Debug, Deserialize)]
37struct QqTokenResponse {
38 #[serde(default)]
39 access_token: String,
40 #[serde(default)]
41 error: Option<String>,
42 #[serde(default)]
43 error_description: Option<String>,
44 #[serde(default)]
45 msg: Option<String>,
46}
47
48#[derive(Debug, Deserialize)]
49struct QqOpenIdResponse {
50 #[serde(default)]
51 client_id: String,
52 #[serde(default)]
53 openid: String,
54 #[serde(default)]
55 error: Option<String>,
56 #[serde(default)]
57 error_description: Option<String>,
58}
59
60#[derive(Debug, Deserialize)]
61struct QqUserInfoResponse {
62 ret: i64,
63 #[serde(default)]
64 msg: Option<String>,
65 #[serde(default)]
66 nickname: Option<String>,
67}
68
69impl QqProviderDriver {
70 pub fn new() -> Self {
72 Self
73 }
74}
75
76#[async_trait]
77impl ExternalAuthProviderDriver for QqProviderDriver {
78 fn kind(&self) -> ExternalAuthProviderKind {
79 ExternalAuthProviderKind::Qq
80 }
81
82 fn descriptor(&self) -> ExternalAuthProviderDescriptor {
83 ExternalAuthProviderDescriptor {
84 kind: ExternalAuthProviderKind::Qq,
85 protocol: ExternalAuthProtocol::OAuth2,
86 display_name: "QQ",
87 description: "QQ Connect OAuth2 sign-in using fixed token, openid and user info endpoints.",
88 default_scopes: QQ_DEFAULT_SCOPES,
89 issuer_url_required: false,
90 manual_endpoint_configuration_supported: false,
91 authorization_url_required: false,
92 token_url_required: false,
93 userinfo_url_required: false,
94 supports_discovery: false,
95 supports_pkce: true,
96 supports_email_verified_claim: false,
97 }
98 }
99
100 async fn start_authorization(
101 &self,
102 provider: &ExternalAuthProviderConfig,
103 redirect_uri: &str,
104 ) -> Result<ExternalAuthAuthorizationStart> {
105 OAuth2ProviderDriver::new()
106 .start_authorization(&qq_oauth2_config(provider), redirect_uri)
107 .await
108 }
109
110 async fn exchange_callback(
111 &self,
112 provider: &ExternalAuthProviderConfig,
113 callback: ExternalAuthCallback,
114 ) -> Result<ExternalAuthProfile> {
115 let provider = qq_oauth2_config(provider);
116 let pkce_verifier = callback.pkce_verifier.ok_or_else(|| {
117 ExternalAuthError::database_operation("stored QQ OAuth2 PKCE verifier is missing")
118 })?;
119 let http_client = oauth2_http_client(&provider)?;
120 let access_token = exchange_qq_code_for_token(
121 &http_client,
122 &provider,
123 &callback.code,
124 &callback.redirect_uri,
125 &pkce_verifier,
126 )
127 .await?;
128 let openid = fetch_qq_openid(&http_client, &provider, &access_token).await?;
129 let userinfo = fetch_qq_userinfo(&http_client, &provider, &access_token, &openid).await?;
130 Ok(ExternalAuthProfile {
131 identity_namespace: qq_identity_namespace(&provider)?,
132 subject: validate_qq_openid(&openid)?,
133 email: None,
134 email_verified: false,
135 display_name: normalize_optional_snapshot(userinfo.nickname),
136 preferred_username: None,
137 })
138 }
139
140 async fn test_provider(
141 &self,
142 provider: &ExternalAuthProviderConfig,
143 ) -> Result<ExternalAuthProviderTestResult> {
144 if provider.client_id.trim().is_empty() {
145 return Err(ExternalAuthError::validation_error("client_id is required"));
146 }
147 let provider = qq_oauth2_config(provider);
148 let authorization_url = provider.authorization_url.as_deref().ok_or_else(|| {
149 ExternalAuthError::validation_error("QQ authorization URL is missing")
150 })?;
151 let token_url = provider
152 .token_url
153 .as_deref()
154 .ok_or_else(|| ExternalAuthError::validation_error("QQ token URL is missing"))?;
155 let userinfo_url = provider
156 .userinfo_url
157 .as_deref()
158 .ok_or_else(|| ExternalAuthError::validation_error("QQ userinfo URL is missing"))?;
159 validate_url(
160 authorization_url,
161 "authorization_url",
162 ExternalAuthError::validation_error,
163 )?;
164 validate_url(token_url, "token_url", ExternalAuthError::validation_error)?;
165 validate_url(
166 userinfo_url,
167 "userinfo_url",
168 ExternalAuthError::validation_error,
169 )?;
170 validate_url(
171 QQ_OPENID_URL,
172 "openid_url",
173 ExternalAuthError::validation_error,
174 )?;
175
176 Ok(ExternalAuthProviderTestResult {
177 provider: self.descriptor().display_name.to_string(),
178 issuer: Some(qq_identity_namespace(&provider)?),
179 authorization_endpoint: Some(authorization_url.to_string()),
180 token_endpoint: Some(token_url.to_string()),
181 userinfo_endpoint: Some(userinfo_url.to_string()),
182 jwks_key_count: None,
183 checks: vec![
184 ExternalAuthProviderTestCheck {
185 name: "qq_endpoints".to_string(),
186 success: true,
187 message:
188 "QQ authorization, token, openid and userinfo endpoints are configured"
189 .to_string(),
190 },
191 ExternalAuthProviderTestCheck {
192 name: "qq_openid".to_string(),
193 success: true,
194 message: "QQ openid is fetched before get_user_info during sign-in".to_string(),
195 },
196 ],
197 })
198 }
199}
200
201fn qq_oauth2_config(provider: &ExternalAuthProviderConfig) -> ExternalAuthProviderConfig {
202 let mut provider = provider.clone();
203 provider.provider_kind = ExternalAuthProviderKind::Qq;
204 provider.protocol = ExternalAuthProtocol::OAuth2;
205 provider.issuer_url = Some(
206 qq_identity_namespace(&provider)
207 .unwrap_or_else(|_| format!("{QQ_NAMESPACE_PREFIX}{}", provider.client_id.trim())),
208 );
209 provider.authorization_url = provider
213 .authorization_url
214 .filter(|value| !value.trim().is_empty())
215 .or_else(|| Some(QQ_AUTHORIZATION_URL.to_string()));
216 provider.token_url = provider
217 .token_url
218 .filter(|value| !value.trim().is_empty())
219 .or_else(|| Some(QQ_TOKEN_URL.to_string()));
220 provider.userinfo_url = provider
221 .userinfo_url
222 .filter(|value| !value.trim().is_empty())
223 .or_else(|| Some(QQ_USERINFO_URL.to_string()));
224 provider.scopes = if provider.scopes.trim().is_empty() {
225 QQ_DEFAULT_SCOPES.to_string()
226 } else {
227 provider.scopes.trim().to_string()
228 };
229 provider.subject_claim = Some("openid".to_string());
230 provider.username_claim = None;
231 provider.display_name_claim = Some("nickname".to_string());
232 provider.email_claim = None;
233 provider.email_verified_claim = None;
234 provider.avatar_url_claim = Some("figureurl_qq_2".to_string());
235 provider
236}
237
238async fn exchange_qq_code_for_token(
239 http_client: &reqwest::Client,
240 provider: &ExternalAuthProviderConfig,
241 code: &str,
242 redirect_uri: &str,
243 pkce_verifier: &str,
244) -> Result<String> {
245 let token_url = provider
246 .token_url
247 .as_deref()
248 .ok_or_else(|| ExternalAuthError::config_error("QQ token URL is missing"))?;
249 let mut token_url = validate_url(token_url, "token_url", ExternalAuthError::config_error)?;
250 {
251 let mut query = token_url.query_pairs_mut();
252 query.append_pair("grant_type", "authorization_code");
253 query.append_pair("client_id", &provider.client_id);
254 if let Some(client_secret) = provider
255 .client_secret
256 .as_deref()
257 .map(str::trim)
258 .filter(|secret| !secret.is_empty())
259 {
260 query.append_pair("client_secret", client_secret);
261 }
262 query.append_pair("code", code);
263 query.append_pair("redirect_uri", redirect_uri);
264 query.append_pair("code_verifier", pkce_verifier);
267 query.append_pair("fmt", "json");
268 }
269 let response = http_client
270 .get(token_url)
271 .header(header::ACCEPT, "application/json")
272 .send()
273 .await
274 .map_external_auth_err_ctx(
275 "QQ token exchange failed",
276 ExternalAuthError::auth_invalid_credentials,
277 )?;
278 if !response.status().is_success() {
279 return Err(oauth2_endpoint_error(response, "QQ token exchange").await);
280 }
281 let token_response = response
282 .json::<QqTokenResponse>()
283 .await
284 .map_external_auth_err_ctx(
285 "QQ token response is invalid",
286 ExternalAuthError::auth_invalid_credentials,
287 )?;
288 if token_response.access_token.trim().is_empty() {
289 return Err(ExternalAuthError::auth_invalid_credentials(format!(
290 "QQ token response missing access_token{}",
291 qq_error_suffix(
292 token_response.error.as_deref(),
293 token_response
294 .error_description
295 .as_deref()
296 .or(token_response.msg.as_deref())
297 )
298 )));
299 }
300 Ok(token_response.access_token)
301}
302
303async fn fetch_qq_openid(
304 http_client: &reqwest::Client,
305 provider: &ExternalAuthProviderConfig,
306 access_token: &str,
307) -> Result<String> {
308 let mut openid_url = qq_openid_url(provider)?;
309 {
310 let mut query = openid_url.query_pairs_mut();
311 query.append_pair("access_token", access_token);
312 query.append_pair("fmt", "json");
313 }
314 let response = http_client
315 .get(openid_url)
316 .header(header::ACCEPT, "application/json")
317 .send()
318 .await
319 .map_external_auth_err_ctx(
320 "QQ openid request failed",
321 ExternalAuthError::auth_invalid_credentials,
322 )?;
323 if !response.status().is_success() {
324 return Err(oauth2_endpoint_error(response, "QQ openid request").await);
325 }
326 let openid_response = response
327 .json::<QqOpenIdResponse>()
328 .await
329 .map_external_auth_err_ctx(
330 "QQ openid response is invalid",
331 ExternalAuthError::auth_invalid_credentials,
332 )?;
333 if !openid_response.client_id.is_empty() && openid_response.client_id != provider.client_id {
334 return Err(ExternalAuthError::auth_invalid_credentials(
335 "QQ openid response client_id does not match provider",
336 ));
337 }
338 if openid_response.openid.trim().is_empty() {
339 return Err(ExternalAuthError::auth_invalid_credentials(format!(
340 "QQ openid response missing openid{}",
341 qq_error_suffix(
342 openid_response.error.as_deref(),
343 openid_response.error_description.as_deref()
344 )
345 )));
346 }
347 Ok(openid_response.openid)
348}
349
350async fn fetch_qq_userinfo(
351 http_client: &reqwest::Client,
352 provider: &ExternalAuthProviderConfig,
353 access_token: &str,
354 openid: &str,
355) -> Result<QqUserInfoResponse> {
356 let userinfo_url = provider
357 .userinfo_url
358 .as_deref()
359 .ok_or_else(|| ExternalAuthError::config_error("QQ userinfo URL is missing"))?;
360 let mut userinfo_url = validate_url(
361 userinfo_url,
362 "userinfo_url",
363 ExternalAuthError::config_error,
364 )?;
365 {
366 let mut query = userinfo_url.query_pairs_mut();
367 query.append_pair("access_token", access_token);
368 query.append_pair("oauth_consumer_key", &provider.client_id);
369 query.append_pair("openid", openid);
370 }
371 let response = http_client
372 .get(userinfo_url)
373 .header(header::ACCEPT, "application/json")
374 .send()
375 .await
376 .map_external_auth_err_ctx(
377 "QQ userinfo request failed",
378 ExternalAuthError::auth_invalid_credentials,
379 )?;
380 if !response.status().is_success() {
381 return Err(oauth2_endpoint_error(response, "QQ userinfo request").await);
382 }
383 let userinfo = response
384 .json::<QqUserInfoResponse>()
385 .await
386 .map_external_auth_err_ctx(
387 "QQ userinfo response is invalid",
388 ExternalAuthError::auth_invalid_credentials,
389 )?;
390 if userinfo.ret != 0 {
391 return Err(ExternalAuthError::auth_invalid_credentials(format!(
392 "QQ userinfo request failed{}",
393 qq_error_suffix(Some(&userinfo.ret.to_string()), userinfo.msg.as_deref())
394 )));
395 }
396 Ok(userinfo)
397}
398
399fn qq_identity_namespace(provider: &ExternalAuthProviderConfig) -> Result<String> {
400 let client_id = provider.client_id.trim();
401 if client_id.is_empty() || client_id.chars().any(char::is_control) {
402 return Err(ExternalAuthError::validation_error(
403 "QQ client_id is invalid",
404 ));
405 }
406 Ok(format!("{QQ_NAMESPACE_PREFIX}{client_id}"))
407}
408
409fn qq_openid_url(provider: &ExternalAuthProviderConfig) -> Result<reqwest::Url> {
410 let token_url = provider
411 .token_url
412 .as_deref()
413 .filter(|value| !value.trim().is_empty())
414 .unwrap_or(QQ_TOKEN_URL);
415 if token_url == QQ_TOKEN_URL {
416 return validate_url(QQ_OPENID_URL, "openid_url", ExternalAuthError::config_error);
417 }
418 let parsed = validate_url(token_url, "openid_url", ExternalAuthError::config_error)?;
419 qq_openid_url_from_token_url(parsed)
420}
421
422fn qq_openid_url_from_token_url(mut token_url: reqwest::Url) -> Result<reqwest::Url> {
423 {
424 let mut paths = token_url
425 .path_segments_mut()
426 .map_err(|_| ExternalAuthError::config_error("invalid QQ token URL"))?;
427 paths.pop_if_empty();
428 paths.pop();
429 paths.push("me");
430 }
431 token_url.set_query(None);
432 token_url.set_fragment(None);
433 Ok(token_url)
434}
435
436fn validate_qq_openid(value: &str) -> Result<String> {
437 let value = value.trim();
438 if value.is_empty() || value.len() > QQ_OPENID_MAX_LEN || value.chars().any(char::is_control) {
439 return Err(ExternalAuthError::auth_invalid_credentials(
440 "QQ openid claim is invalid",
441 ));
442 }
443 Ok(value.to_string())
444}
445
446fn normalize_optional_snapshot(value: Option<String>) -> Option<String> {
447 value
448 .map(|value| {
449 value
450 .chars()
451 .filter(|ch| !ch.is_control())
452 .collect::<String>()
453 })
454 .map(|value| value.trim().to_string())
455 .filter(|value| !value.is_empty())
456 .map(|value| truncate_to_utf8_boundary(&value, QQ_SNAPSHOT_MAX_LEN))
457}
458
459fn truncate_to_utf8_boundary(value: &str, max_len: usize) -> String {
460 if value.len() <= max_len {
461 return value.to_string();
462 }
463 let mut end = max_len;
464 while !value.is_char_boundary(end) {
465 end -= 1;
466 }
467 value[..end].to_string()
468}
469
470fn qq_error_suffix(error: Option<&str>, description: Option<&str>) -> String {
471 let mut parts = Vec::new();
472 if let Some(error) = error
473 .map(sanitize_qq_error)
474 .filter(|value| !value.is_empty())
475 {
476 parts.push(format!("error={error}"));
477 }
478 if let Some(description) = description
479 .map(sanitize_qq_error)
480 .filter(|value| !value.is_empty())
481 {
482 parts.push(format!("description={description}"));
483 }
484 if parts.is_empty() {
485 String::new()
486 } else {
487 format!(" ({})", parts.join("; "))
488 }
489}
490
491fn sanitize_qq_error(value: &str) -> String {
492 value
493 .chars()
494 .filter(|ch| !ch.is_control())
495 .take(128)
496 .collect::<String>()
497 .trim()
498 .to_string()
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 fn provider() -> ExternalAuthProviderConfig {
506 ExternalAuthProviderConfig {
507 id: 1,
508 key: "qq".to_string(),
509 provider_kind: ExternalAuthProviderKind::Qq,
510 protocol: ExternalAuthProtocol::OAuth2,
511 options: Default::default(),
512 issuer_url: Some("https://ignored.example.com".to_string()),
513 authorization_url: Some("https://ignored.example.com/auth".to_string()),
514 token_url: Some("https://ignored.example.com/token".to_string()),
515 userinfo_url: Some("https://ignored.example.com/userinfo".to_string()),
516 client_id: "100000001".to_string(),
517 client_secret: Some("secret".to_string()),
518 scopes: String::new(),
519 subject_claim: Some("sub".to_string()),
520 username_claim: Some("login".to_string()),
521 display_name_claim: Some("name".to_string()),
522 email_claim: Some("email".to_string()),
523 email_verified_claim: Some("email_verified".to_string()),
524 groups_claim: None,
525 avatar_url_claim: None,
526 outbound_http_user_agent: None,
527 }
528 }
529
530 #[test]
531 fn qq_config_uses_fixed_endpoints_and_claim_semantics() {
532 let mut provider = provider();
533 provider.authorization_url = None;
534 provider.token_url = None;
535 provider.userinfo_url = None;
536 let config = qq_oauth2_config(&provider);
537
538 assert_eq!(config.provider_kind, ExternalAuthProviderKind::Qq);
539 assert_eq!(config.protocol, ExternalAuthProtocol::OAuth2);
540 assert_eq!(config.issuer_url.as_deref(), Some("qq:100000001"));
541 assert_eq!(
542 config.authorization_url.as_deref(),
543 Some(QQ_AUTHORIZATION_URL)
544 );
545 assert_eq!(config.token_url.as_deref(), Some(QQ_TOKEN_URL));
546 assert_eq!(config.userinfo_url.as_deref(), Some(QQ_USERINFO_URL));
547 assert_eq!(config.scopes, QQ_DEFAULT_SCOPES);
548 assert_eq!(config.subject_claim.as_deref(), Some("openid"));
549 assert_eq!(config.username_claim, None);
550 assert_eq!(config.display_name_claim.as_deref(), Some("nickname"));
551 assert_eq!(config.email_claim, None);
552 assert_eq!(config.email_verified_claim, None);
553 assert_eq!(config.avatar_url_claim.as_deref(), Some("figureurl_qq_2"));
554 }
555
556 #[test]
557 fn qq_identity_namespace_is_client_scoped() {
558 let mut first = provider();
559 first.client_id = "100000001".to_string();
560 let mut second = provider();
561 second.client_id = "200000002".to_string();
562
563 assert_eq!(qq_identity_namespace(&first).unwrap(), "qq:100000001");
564 assert_eq!(qq_identity_namespace(&second).unwrap(), "qq:200000002");
565 }
566
567 #[test]
568 fn qq_openid_url_preserves_mock_path_prefix() {
569 let openid_url = qq_openid_url_from_token_url(
570 reqwest::Url::parse("http://127.0.0.1:3000/prefix/qq/token?fmt=json#fragment").unwrap(),
571 )
572 .unwrap();
573
574 assert_eq!(openid_url.as_str(), "http://127.0.0.1:3000/prefix/qq/me");
575 }
576
577 #[test]
578 fn qq_openid_validation_rejects_empty_control_and_long_values() {
579 assert_eq!(validate_qq_openid(" openid-1 ").unwrap(), "openid-1");
580 assert!(validate_qq_openid("").is_err());
581 assert!(validate_qq_openid("open\nid").is_err());
582 assert!(validate_qq_openid(&"a".repeat(QQ_OPENID_MAX_LEN + 1)).is_err());
583 }
584}