From cd09bff247f89dfb432cc697de6f0fac6b46cd39 Mon Sep 17 00:00:00 2001 From: Anduin Xue Date: Wed, 10 Dec 2025 20:48:12 +0800 Subject: [PATCH] sources/oauth: add WeChat type (#18086) * Add wechat. * Refactor comments and formatting in wechat.py Signed-off-by: Anduin Xue * Fix lint. Signed-off-by: Anduin Xue * Fix lint. * fix: Rename `WeChat` enum member to `Wechat` for consistency * docs: Add WeChat social login integration guide. * Docs updates * Revise WeChat integration instructions Updated instructions for creating a WeChat Website Application and added details about scopes and user attribute mappings. Signed-off-by: Anduin Xue * Prettier * Update wechat.py Signed-off-by: Anduin Xue --------- Signed-off-by: Anduin Xue Co-authored-by: dewi-tik Co-authored-by: Marc 'risson' Schmitt --- authentik/sources/oauth/apps.py | 1 + authentik/sources/oauth/models.py | 9 + .../sources/oauth/tests/test_type_wechat.py | 52 ++++++ authentik/sources/oauth/types/wechat.py | 162 ++++++++++++++++++ blueprints/schema.json | 3 +- schema.yml | 1 + web/authentik/sources/wechat.svg | 1 + .../sources/oauth/OAuthSourceViewPage.ts | 2 + website/docs/sidebar.mjs | 1 + .../sources/social-logins/wechat/index.md | 82 +++++++++ 10 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 authentik/sources/oauth/tests/test_type_wechat.py create mode 100644 authentik/sources/oauth/types/wechat.py create mode 100644 web/authentik/sources/wechat.svg create mode 100644 website/docs/users-sources/sources/social-logins/wechat/index.md diff --git a/authentik/sources/oauth/apps.py b/authentik/sources/oauth/apps.py index ced0e718cd..032154c48f 100644 --- a/authentik/sources/oauth/apps.py +++ b/authentik/sources/oauth/apps.py @@ -25,6 +25,7 @@ AUTHENTIK_SOURCES_OAUTH_TYPES = [ "authentik.sources.oauth.types.slack", "authentik.sources.oauth.types.twitch", "authentik.sources.oauth.types.twitter", + "authentik.sources.oauth.types.wechat", ] diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index 2232d5b128..a681b9a1b1 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -307,6 +307,15 @@ class RedditOAuthSource(CreatableType, OAuthSource): verbose_name_plural = _("Reddit OAuth Sources") +class WeChatOAuthSource(CreatableType, OAuthSource): + """Social Login using WeChat.""" + + class Meta: + abstract = True + verbose_name = _("WeChat OAuth Source") + verbose_name_plural = _("WeChat OAuth Sources") + + class OAuthSourcePropertyMapping(PropertyMapping): """Map OAuth properties to User or Group object attributes""" diff --git a/authentik/sources/oauth/tests/test_type_wechat.py b/authentik/sources/oauth/tests/test_type_wechat.py new file mode 100644 index 0000000000..5c6a0df41d --- /dev/null +++ b/authentik/sources/oauth/tests/test_type_wechat.py @@ -0,0 +1,52 @@ +"""WeChat Type tests""" + +from django.test import RequestFactory, TestCase + +from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.types.wechat import WeChatType + +WECHAT_USER = { + "openid": "OPENID", + "nickname": "NICKNAME", + "sex": 1, + "province": "PROVINCE", + "city": "CITY", + "country": "COUNTRY", + "headimgurl": "https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0", + "privilege": ["PRIVILEGE1", "PRIVILEGE2"], + "unionid": " o6_buyCrymLUUFYHxvDU6M2PHl22", +} + + +class TestTypeWeChat(TestCase): + """OAuth Source tests""" + + def setUp(self): + self.source = OAuthSource.objects.create( + name="test", + slug="test", + provider_type="wechat", + ) + self.factory = RequestFactory() + + def test_enroll_context(self): + """Test WeChat Enrollment context""" + ak_context = WeChatType().get_base_user_properties( + source=self.source, info=WECHAT_USER, client=None, token={} + ) + self.assertEqual(ak_context["username"], WECHAT_USER["unionid"]) + self.assertIsNone(ak_context["email"]) + self.assertEqual(ak_context["name"], WECHAT_USER["nickname"]) + self.assertEqual(ak_context["attributes"]["openid"], WECHAT_USER["openid"]) + self.assertEqual(ak_context["attributes"]["unionid"], WECHAT_USER["unionid"]) + + def test_enroll_context_no_unionid(self): + """Test WeChat Enrollment context without unionid""" + user = WECHAT_USER.copy() + del user["unionid"] + ak_context = WeChatType().get_base_user_properties( + source=self.source, info=user, client=None, token={} + ) + self.assertEqual(ak_context["username"], WECHAT_USER["openid"]) + self.assertIsNone(ak_context["email"]) + self.assertEqual(ak_context["name"], WECHAT_USER["nickname"]) diff --git a/authentik/sources/oauth/types/wechat.py b/authentik/sources/oauth/types/wechat.py new file mode 100644 index 0000000000..eaa31c20e2 --- /dev/null +++ b/authentik/sources/oauth/types/wechat.py @@ -0,0 +1,162 @@ +"""WeChat (Weixin) OAuth Views""" + +from typing import Any + +from requests.exceptions import RequestException + +from authentik.sources.oauth.clients.oauth2 import OAuth2Client +from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.types.registry import SourceType, registry +from authentik.sources.oauth.views.callback import OAuthCallback +from authentik.sources.oauth.views.redirect import OAuthRedirect + + +class WeChatOAuthRedirect(OAuthRedirect): + """WeChat OAuth2 Redirect""" + + def get_additional_parameters(self, source: OAuthSource): # pragma: no cover + # WeChat (Weixin) for Websites official documentation requires 'snsapi_login' + # as the *only* scope for the QR code-based login flow. + # Ref: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html (Step 1) # noqa: E501 + return { + "scope": ["snsapi_login"], + } + + +class WeChatOAuth2Client(OAuth2Client): + """ + WeChat OAuth2 Client + + Handles the non-standard parts of the WeChat OAuth2 flow. + """ + + def get_access_token(self, redirect_uri: str, code: str) -> dict[str, Any]: + """ + Get access token from WeChat. + + WeChat uses a non-standard GET request for the token exchange, + unlike the standard OAuth2 POST request. The AppID (client_id) + and AppSecret (client_secret) are passed as URL query parameters. + """ + token_url = self.get_access_token_url() + params = { + "appid": self.get_client_id(), + "secret": self.get_client_secret(), + "code": code, + "grant_type": "authorization_code", + } + + # Send the GET request using the base class's session handler + response = self.do_request("get", token_url, params=params) + + try: + response.raise_for_status() + except RequestException as exc: + self.logger.warning("Unable to fetch wechat token", exc=exc) + raise exc + + data = response.json() + + # Handle WeChat's specific error format (JSON with 'errcode' and 'errmsg') + if "errcode" in data: + self.logger.warning( + "Unable to fetch wechat token", + errcode=data.get("errcode"), + errmsg=data.get("errmsg"), + ) + raise RequestException(data.get("errmsg")) + + return data + + def get_profile_info(self, token: dict[str, Any]) -> dict[str, Any]: + """ + Get Userinfo from WeChat. + + This API call requires both the 'access_token' and the 'openid' + (which was returned during the token exchange). + """ + profile_url = self.get_profile_url() + params = { + "access_token": token.get("access_token"), + "openid": token.get("openid"), + "lang": "en", # or 'zh_CN' (Simplified Chinese), 'zh_TW' (Traditional) + } + + response = self.do_request("get", profile_url, params=params) + + try: + response.raise_for_status() + except RequestException as exc: + self.logger.warning("Unable to fetch wechat userinfo", exc=exc) + raise exc + + data = response.json() + + # Handle WeChat's specific error format + if "errcode" in data: + self.logger.warning( + "Unable to fetch wechat userinfo", + errcode=data.get("errcode"), + errmsg=data.get("errmsg"), + ) + raise RequestException(data.get("errmsg")) + + return data + + +class WeChatOAuth2Callback(OAuthCallback): + """WeChat OAuth2 Callback""" + + # Specify our custom Client to handle the non-standard WeChat flow + client_class = WeChatOAuth2Client + + +@registry.register() +class WeChatType(SourceType): + """WeChat Type definition""" + + callback_view = WeChatOAuth2Callback + redirect_view = WeChatOAuthRedirect + verbose_name = "WeChat" + name = "wechat" + + # WeChat API URLs are fixed and not customizable + urls_customizable = False + + # URLs for the WeChat "Login for Websites" authorization flow + authorization_url = "https://open.weixin.qq.com/connect/qrconnect" + # nosec: B105 This is a public URL, not a hardcoded secret + access_token_url = "https://api.weixin.qq.com/sns/oauth2/access_token" # nosec + profile_url = "https://api.weixin.qq.com/sns/userinfo" + + # Note: 'authorization_code_auth_method' is intentionally omitted. + # The base OAuth2Client defaults to POST_BODY, but our custom + # WeChatOAuth2Client overrides get_access_token() to use GET, + # so this setting would be misleading. + + def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]: + """ + Map WeChat userinfo to authentik user properties. + """ + # The WeChat userinfo API (sns/userinfo) does *not* return an email address. + # We explicitly set 'email' to None. Authentik will typically + # prompt the user to provide one on their first login if it's required. + + # 'unionid' is the preferred unique identifier as it's consistent + # across multiple apps under the same WeChat Open Platform account. + # 'openid' is the fallback, which is only unique to this specific AppID. + return { + "username": info.get("unionid", info.get("openid")), + "email": None, # WeChat API does not provide Email + "name": info.get("nickname"), + "attributes": { + # Save all other relevant info as user attributes + "headimgurl": info.get("headimgurl"), + "sex": info.get("sex"), + "city": info.get("city"), + "province": info.get("province"), + "country": info.get("country"), + "unionid": info.get("unionid"), + "openid": info.get("openid"), + }, + } diff --git a/blueprints/schema.json b/blueprints/schema.json index c42610453c..afb142a8b9 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -11982,7 +11982,8 @@ "reddit", "slack", "twitch", - "twitter" + "twitter", + "wechat" ], "title": "Provider type" }, diff --git a/schema.yml b/schema.yml index 9d20dc00d5..614bbe37a2 100644 --- a/schema.yml +++ b/schema.yml @@ -50080,6 +50080,7 @@ components: - slack - twitch - twitter + - wechat type: string ProxyMode: enum: diff --git a/web/authentik/sources/wechat.svg b/web/authentik/sources/wechat.svg new file mode 100644 index 0000000000..e39026b435 --- /dev/null +++ b/web/authentik/sources/wechat.svg @@ -0,0 +1 @@ +WeChat diff --git a/web/src/admin/sources/oauth/OAuthSourceViewPage.ts b/web/src/admin/sources/oauth/OAuthSourceViewPage.ts index 3c6c762829..c524ea0e88 100644 --- a/web/src/admin/sources/oauth/OAuthSourceViewPage.ts +++ b/web/src/admin/sources/oauth/OAuthSourceViewPage.ts @@ -71,6 +71,8 @@ export function ProviderToLabel(provider?: ProviderTypeEnum): string { return "Twitter"; case ProviderTypeEnum.Twitch: return "Twitch"; + case ProviderTypeEnum.Wechat: + return "WeChat"; case ProviderTypeEnum.UnknownDefaultOpenApi: return msg("Unknown provider type"); } diff --git a/website/docs/sidebar.mjs b/website/docs/sidebar.mjs index b8143db77f..98955fd82a 100644 --- a/website/docs/sidebar.mjs +++ b/website/docs/sidebar.mjs @@ -615,6 +615,7 @@ const items = [ "users-sources/sources/social-logins/telegram/index", "users-sources/sources/social-logins/twitch/index", "users-sources/sources/social-logins/twitter/index", + "users-sources/sources/social-logins/wechat/index", ], }, ], diff --git a/website/docs/users-sources/sources/social-logins/wechat/index.md b/website/docs/users-sources/sources/social-logins/wechat/index.md new file mode 100644 index 0000000000..eec37d03c5 --- /dev/null +++ b/website/docs/users-sources/sources/social-logins/wechat/index.md @@ -0,0 +1,82 @@ +--- +title: WeChat +tags: + - source + - wechat +--- + +Allows users to authenticate using their WeChat credentials by configuring WeChat as a federated identity provider via OAuth2. + +## Preparation + +The following placeholders are used in this guide: + +- `authentik.company` is the FQDN of the authentik installation. + +## WeChat configuration + +To integrate WeChat with authentik you will need to register a "Website Application" (网站应用) on the [WeChat Open Platform](https://open.weixin.qq.com/). + +1. Register for a developer account on the [WeChat Open Platform](https://open.weixin.qq.com/). +2. Navigate to the **Management Center** (管理中心) > **Website Application** (网站应用) and click **Create Website Application** (创建网站应用). +3. Submit the application for review. +4. Once approved, you will obtain an **AppID** and **AppSecret**. +5. In the WeChat application settings, configure the **Authorized Callback Domain** (授权回调域) to match your authentik domain (e.g. `authentik.company`). + +:::info +This integration uses the WeChat "Website Application" login flow (QR Code login). When users access the login page on a desktop device (Windows/Mac) with the WeChat client installed, they may see a "Fast Login" prompt. +::: + +## authentik configuration + +To support the integration of WeChat with authentik, you need to create a WeChat OAuth source in authentik. + +1. Log in to authentik as an administrator and open the authentik Admin interface. +2. Navigate to **Directory** > **Federation and Social login**, click **Create**, and then configure the following settings: + - **Select type**: select **WeChat OAuth Source** as the source type. + - **Create OAuth Source**: provide a name, a slug (e.g. `wechat`), and set the following required configurations: + - **Protocol settings** + - **Consumer Key**: Enter the **AppID** from the WeChat Open Platform. + - **Consumer Secret**: Enter the **AppSecret** from the WeChat Open Platform. + - **Scopes**: define any further access scopes. +3. Click **Finish**. + +:::info Display new source on login screen +For instructions on how to display the new source on the authentik login page, refer to the [Add sources to default login page documentation](../../index.md#add-sources-to-default-login-page). +::: + +:::info Embed new source in flow :ak-enterprise +For instructions on embedding the new source within a flow, such as an authorization flow, refer to the [Source Stage documentation](../../../../../add-secure-apps/flows-stages/stages/source/). +::: + +## Source property mappings + +Source property mappings allow you to modify or gather extra information from sources. See the [overview](../../property-mappings/index.md) for more information. + +The following data is retrieved from WeChat and mapped to the user's attributes in authentik: + +| WeChat Field | authentik Attribute | Description | +| :---------------------- | :---------------------- | :------------------------------ | +| `unionid` (or `openid`) | `username` | Used as the primary identifier. | +| `nickname` | `name` | The user's display name. | +| `headimgurl` | `attributes.headimgurl` | URL to the user's avatar. | +| `sex` | `attributes.sex` | Gender (1=Male, 2=Female). | +| `city` | `attributes.city` | User's city. | +| `province` | `attributes.province` | User's province. | +| `country` | `attributes.country` | User's country. | + +### User Matching + +WeChat users are identified by their `unionid` (if available) or `openid`. + +- **UnionID**: Unique across multiple applications under the same developer account. authentik prioritizes this as the username. +- **OpenID**: Unique to the specific application. Used as a fallback if `unionid` is not returned. + +:::info +WeChat does not provide the user's email address via the API. +::: + +## Resources + +- [WeChat Open Platform](https://open.weixin.qq.com/) +- [WeChat Login document](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html)