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