Skip to main content

aster_forge_external_auth/providers/
qq.rs

1//! QQ Connect OAuth2 provider driver.
2//!
3//! QQ Connect uses fixed OAuth2 endpoints plus a provider-specific openid lookup before fetching
4//! user information. The driver keeps the QQ namespace scoped by client id and returns a normalized
5//! profile without email claims because QQ does not provide a verified email in this flow.
6
7use 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/// QQ Connect OAuth2 provider driver.
33#[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    /// Creates a QQ Connect provider driver with fixed OAuth2 endpoints.
71    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    // Admin create/update rejects manual QQ endpoints through the descriptor.
210    // Non-empty values are kept only for integration tests that inject a local
211    // QQ-compatible mock server instead of calling the real QQ Connect API.
212    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        // QQ Connect docs do not list PKCE, but authorization uses the shared
265        // OAuth2 driver which sends a code_challenge, so keep the token request paired.
266        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}