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    #[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    // Admin create/update rejects manual QQ endpoints through the descriptor.
211    // Non-empty values are kept only for integration tests that inject a local
212    // QQ-compatible mock server instead of calling the real QQ Connect API.
213    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        // QQ Connect docs do not list PKCE, but authorization uses the shared
266        // OAuth2 driver which sends a code_challenge, so keep the token request paired.
267        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}