Skip to content

Commit 000832b

Browse files
authored
feat: Add Link App OAuth provider support and update related configurations (#3068)
1 parent 132c788 commit 000832b

6 files changed

Lines changed: 134 additions & 23 deletions

File tree

backend/consts/oauth_providers.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,32 @@
4747
client_secret_env="GDE_OAUTH_CLIENT_SECRET",
4848
)
4949

50+
LINK_APP_PROVIDER = OAuthProviderDefinition(
51+
name="link_app",
52+
display_name="Link App",
53+
icon="link_app",
54+
authorize_url=f"{os.getenv('LINK_APP_URL')}/CNS/oauth2/authorize",
55+
authorize_params={"response_type": "code", "scope": "read write"},
56+
token_url=f"{os.getenv('LINK_APP_URL')}/CNS/oauth2/token",
57+
token_params_map={
58+
"client_id": "client_id",
59+
"client_secret": "client_secret",
60+
"code": "code",
61+
"grant_type": "grant_type",
62+
"redirect_uri": "redirect_uri",
63+
},
64+
token_error_key="error",
65+
token_error_message_key="error_description",
66+
userinfo_url=f"{os.getenv('LINK_APP_URL')}/CNS/getUserInfo",
67+
userinfo_field_map={
68+
"id": "data.id",
69+
"email": "data.email",
70+
"username": "data.username",
71+
},
72+
client_id_env="LINK_APP_OAUTH_CLIENT_ID",
73+
client_secret_env="LINK_APP_OAUTH_CLIENT_SECRET",
74+
)
75+
5076
WECHAT_PROVIDER = OAuthProviderDefinition(
5177
name="wechat",
5278
display_name="WeChat",
@@ -89,6 +115,7 @@
89115
"github": GITHUB_PROVIDER,
90116
"wechat": WECHAT_PROVIDER,
91117
"gde": GDE_PROVIDER,
118+
"link_app": LINK_APP_PROVIDER,
92119
}
93120

94121

backend/database/db_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,7 @@ class UserOAuthAccount(TableBase):
709709
)
710710
user_id = Column(String(100), nullable=False, doc="Supabase user UUID")
711711
provider = Column(
712-
String(30), nullable=False, doc="OAuth provider name: github, wechat"
712+
String(30), nullable=False, doc="OAuth provider name: github, wechat, gde, link_app"
713713
)
714714
provider_user_id = Column(
715715
String(200), nullable=False, doc="User ID from the OAuth provider"

docker/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ GITHUB_OAUTH_CLIENT_SECRET=
211211
GDE_URL=
212212
GDE_OAUTH_CLIENT_ID=
213213
GDE_OAUTH_CLIENT_SECRET=
214+
# Link App OAuth
215+
LINK_APP_URL=
216+
LINK_APP_OAUTH_CLIENT_ID=
217+
LINK_APP_OAUTH_CLIENT_SECRET=
214218
# WeChat OAuth (set ENABLE_WECHAT_OAUTH=true to enable)
215219
ENABLE_WECHAT_OAUTH=false
216220
WECHAT_OAUTH_APP_ID=

docker/init.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1775,7 +1775,7 @@ EXECUTE FUNCTION update_user_oauth_account_t_update_time();
17751775
COMMENT ON TABLE nexent.user_oauth_account_t IS 'User OAuth account table - third-party login bindings';
17761776
COMMENT ON COLUMN nexent.user_oauth_account_t.oauth_account_id IS 'OAuth account ID, primary key';
17771777
COMMENT ON COLUMN nexent.user_oauth_account_t.user_id IS 'Nexent user ID (Supabase UUID)';
1778-
COMMENT ON COLUMN nexent.user_oauth_account_t.provider IS 'OAuth provider name: github, wechat';
1778+
COMMENT ON COLUMN nexent.user_oauth_account_t.provider IS 'OAuth provider name: github, wechat, gde, link_app';
17791779
COMMENT ON COLUMN nexent.user_oauth_account_t.provider_user_id IS 'User ID from the OAuth provider';
17801780
COMMENT ON COLUMN nexent.user_oauth_account_t.provider_email IS 'Email from the OAuth provider';
17811781
COMMENT ON COLUMN nexent.user_oauth_account_t.provider_username IS 'Display name from the OAuth provider';

docker/sql/v2.0.3_0430_add_user_oauth_account_t.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ EXECUTE FUNCTION update_user_oauth_account_t_update_time();
3636
COMMENT ON TABLE nexent.user_oauth_account_t IS 'User OAuth account table - third-party login bindings';
3737
COMMENT ON COLUMN nexent.user_oauth_account_t.oauth_account_id IS 'OAuth account ID, primary key';
3838
COMMENT ON COLUMN nexent.user_oauth_account_t.user_id IS 'Nexent user ID (Supabase UUID)';
39-
COMMENT ON COLUMN nexent.user_oauth_account_t.provider IS 'OAuth provider name: github, wechat';
39+
COMMENT ON COLUMN nexent.user_oauth_account_t.provider IS 'OAuth provider name: github, wechat, gde, link_app';
4040
COMMENT ON COLUMN nexent.user_oauth_account_t.provider_user_id IS 'User ID from the OAuth provider';
4141
COMMENT ON COLUMN nexent.user_oauth_account_t.provider_email IS 'Email from the OAuth provider';
4242
COMMENT ON COLUMN nexent.user_oauth_account_t.provider_username IS 'Display name from the OAuth provider';

test/backend/services/test_oauth_service.py

Lines changed: 100 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,55 @@ def __repr__(self):
175175
enabled_check=None,
176176
)
177177

178+
LINK_APP_DEF = _FakeOAuthProviderDefinition(
179+
name="link_app",
180+
display_name="Link App",
181+
icon="link_app",
182+
authorize_url="https://linkapp.test/CNS/oauth2/authorize",
183+
authorize_method="GET",
184+
authorize_params={"response_type": "code", "scope": "read write"},
185+
authorize_fragment="",
186+
authorize_param_map={
187+
"client_id": "client_id",
188+
"redirect_uri": "redirect_uri",
189+
"scope": "scope",
190+
"state": "state",
191+
},
192+
encode_redirect_uri=False,
193+
token_url="https://linkapp.test/CNS/oauth2/token",
194+
token_method="POST",
195+
token_params_map={
196+
"client_id": "client_id",
197+
"client_secret": "client_secret",
198+
"code": "code",
199+
"grant_type": "grant_type",
200+
"redirect_uri": "redirect_uri",
201+
},
202+
token_extra_params={},
203+
token_error_key="error",
204+
token_error_message_key="error_description",
205+
token_response_id_key=None,
206+
userinfo_url="https://linkapp.test/BGM/deparment/syncDept",
207+
userinfo_auth_scheme="Bearer",
208+
userinfo_params={},
209+
userinfo_field_map={
210+
"id": "id",
211+
"email": "email",
212+
"username": "login",
213+
},
214+
userinfo_needs_email_fetch=False,
215+
userinfo_email_url=None,
216+
client_id_env="LINK_APP_OAUTH_CLIENT_ID",
217+
client_secret_env="LINK_APP_OAUTH_CLIENT_SECRET",
218+
enabled_check=None,
219+
)
220+
178221
oauth_providers_mock = MagicMock()
179222
oauth_providers_mock.OAUTH_PROVIDER_REGISTRY = {
180223
"github": GITHUB_DEF,
181224
"wechat": WECHAT_DEF,
182225
"gde": GDE_DEF,
226+
"link_app": LINK_APP_DEF,
183227
}
184228

185229

@@ -271,7 +315,6 @@ def test_returns_none_for_missing_nested_field(self):
271315
result = _resolve_field(data, "attributes.userId")
272316
self.assertIsNone(result)
273317

274-
275318
class TestBuildSSLContext(unittest.TestCase):
276319
def test_returns_default_context_when_verify_enabled(self):
277320
ctx = _build_ssl_context()
@@ -287,14 +330,22 @@ def test_returns_no_verify_context_when_disabled(self):
287330
class TestGetSupportedProviders(unittest.TestCase):
288331
def test_supported_providers_set(self):
289332
providers = get_supported_providers()
290-
self.assertEqual(providers, {"github", "wechat", "gde"})
333+
self.assertEqual(providers, {"github", "wechat", "gde", "link_app"})
291334

292335

293336
class TestGetEnabledProviders(unittest.TestCase):
294337
def test_returns_github_when_configured(self):
295338
with patch.dict(
296339
os.environ,
297-
{"GITHUB_OAUTH_CLIENT_ID": "id", "GITHUB_OAUTH_CLIENT_SECRET": "secret"},
340+
{
341+
"GITHUB_OAUTH_CLIENT_ID": "id",
342+
"GITHUB_OAUTH_CLIENT_SECRET": "secret",
343+
"GDE_OAUTH_CLIENT_ID": "",
344+
"GDE_OAUTH_CLIENT_SECRET": "",
345+
"LINK_APP_OAUTH_CLIENT_ID": "",
346+
"LINK_APP_OAUTH_CLIENT_SECRET": "",
347+
"ENABLE_WECHAT_OAUTH": "false",
348+
},
298349
clear=False,
299350
):
300351
providers = get_enabled_providers()
@@ -309,6 +360,10 @@ def test_returns_empty_when_nothing_configured(self):
309360
for k in [
310361
"GITHUB_OAUTH_CLIENT_ID",
311362
"GITHUB_OAUTH_CLIENT_SECRET",
363+
"GDE_OAUTH_CLIENT_ID",
364+
"GDE_OAUTH_CLIENT_SECRET",
365+
"LINK_APP_OAUTH_CLIENT_ID",
366+
"LINK_APP_OAUTH_CLIENT_SECRET",
312367
"WECHAT_OAUTH_APP_ID",
313368
"WECHAT_OAUTH_APP_SECRET",
314369
]
@@ -326,6 +381,10 @@ def test_returns_both_when_all_configured(self):
326381
"ENABLE_WECHAT_OAUTH": "true",
327382
"WECHAT_OAUTH_APP_ID": "wx_id",
328383
"WECHAT_OAUTH_APP_SECRET": "wx_secret",
384+
"GDE_OAUTH_CLIENT_ID": "",
385+
"GDE_OAUTH_CLIENT_SECRET": "",
386+
"LINK_APP_OAUTH_CLIENT_ID": "",
387+
"LINK_APP_OAUTH_CLIENT_SECRET": "",
329388
}
330389
with patch.dict(os.environ, env, clear=False):
331390
providers = get_enabled_providers()
@@ -343,6 +402,10 @@ def test_returns_github_authorize_url(self):
343402
{
344403
"GITHUB_OAUTH_CLIENT_ID": "gh_test_id",
345404
"GITHUB_OAUTH_CLIENT_SECRET": "gh_test_secret",
405+
"GDE_OAUTH_CLIENT_ID": "",
406+
"GDE_OAUTH_CLIENT_SECRET": "",
407+
"LINK_APP_OAUTH_CLIENT_ID": "",
408+
"LINK_APP_OAUTH_CLIENT_SECRET": "",
346409
},
347410
clear=False,
348411
):
@@ -359,6 +422,10 @@ def test_returns_github_authorize_url_with_link_user_id(self):
359422
{
360423
"GITHUB_OAUTH_CLIENT_ID": "gh_test_id",
361424
"GITHUB_OAUTH_CLIENT_SECRET": "gh_test_secret",
425+
"GDE_OAUTH_CLIENT_ID": "",
426+
"GDE_OAUTH_CLIENT_SECRET": "",
427+
"LINK_APP_OAUTH_CLIENT_ID": "",
428+
"LINK_APP_OAUTH_CLIENT_SECRET": "",
362429
},
363430
clear=False,
364431
):
@@ -380,6 +447,20 @@ def test_returns_wechat_authorize_url(self):
380447
self.assertIn("appid=wx_test_id", url)
381448
self.assertTrue(url.endswith("#wechat_redirect"))
382449

450+
def test_returns_link_app_authorize_url(self):
451+
env = {
452+
"LINK_APP_OAUTH_CLIENT_ID": "link_client",
453+
"LINK_APP_OAUTH_CLIENT_SECRET": "link_secret",
454+
}
455+
with patch.dict(os.environ, env, clear=False):
456+
url = get_authorize_url("link_app")
457+
458+
self.assertIn("linkapp.test/CNS/oauth2/authorize", url)
459+
self.assertIn("client_id=link_client", url)
460+
self.assertIn("response_type=code", url)
461+
self.assertIn("scope=read+write", url)
462+
self.assertIn("state=link_app", url)
463+
383464
def test_unsupported_provider_raises(self):
384465
with self.assertRaises(_OAuthProviderError):
385466
get_authorize_url("google")
@@ -405,7 +486,6 @@ def test_raises_for_unsupported_provider(self):
405486
with self.assertRaises(_OAuthProviderError):
406487
get_provider_user_info("google", "token123")
407488

408-
409489
class TestCreateOrUpdateOAuthAccount(unittest.TestCase):
410490
def test_creates_new_account_when_none_exists(self):
411491
oauth_account_db_mock.reset_mock()
@@ -699,14 +779,14 @@ def test_returns_email_from_primary_in_emails_list(self):
699779
mock_user_resp.read.return_value = b'{"id": "12345", "login": "octocat"}'
700780
mock_emails_resp = MagicMock()
701781
mock_emails_resp.read.return_value = b'[{"email": "secondary@github.com", "primary": false}, {"email": "primary@github.com", "primary": true}]'
702-
782+
703783
mock_cm1 = MagicMock()
704784
mock_cm1.__enter__ = MagicMock(return_value=mock_user_resp)
705785
mock_cm1.__exit__ = MagicMock(return_value=False)
706786
mock_cm2 = MagicMock()
707787
mock_cm2.__enter__ = MagicMock(return_value=mock_emails_resp)
708788
mock_cm2.__exit__ = MagicMock(return_value=False)
709-
789+
710790
with patch("urllib.request.urlopen", side_effect=[mock_cm1, mock_cm2]):
711791
env = {
712792
"GITHUB_OAUTH_CLIENT_ID": "id",
@@ -722,14 +802,14 @@ def test_returns_first_email_when_no_primary(self):
722802
mock_user_resp.read.return_value = b'{"id": "12345", "login": "octocat"}'
723803
mock_emails_resp = MagicMock()
724804
mock_emails_resp.read.return_value = b'[{"email": "first@github.com"}]'
725-
805+
726806
mock_cm1 = MagicMock()
727807
mock_cm1.__enter__ = MagicMock(return_value=mock_user_resp)
728808
mock_cm1.__exit__ = MagicMock(return_value=False)
729809
mock_cm2 = MagicMock()
730810
mock_cm2.__enter__ = MagicMock(return_value=mock_emails_resp)
731811
mock_cm2.__exit__ = MagicMock(return_value=False)
732-
812+
733813
with patch("urllib.request.urlopen", side_effect=[mock_cm1, mock_cm2]):
734814
env = {
735815
"GITHUB_OAUTH_CLIENT_ID": "id",
@@ -745,14 +825,14 @@ def test_fallback_email_when_no_email_found(self):
745825
mock_user_resp.read.return_value = b'{"id": "12345", "login": "testuser"}'
746826
mock_emails_resp = MagicMock()
747827
mock_emails_resp.read.return_value = b'[]'
748-
828+
749829
mock_cm1 = MagicMock()
750830
mock_cm1.__enter__ = MagicMock(return_value=mock_user_resp)
751831
mock_cm1.__exit__ = MagicMock(return_value=False)
752832
mock_cm2 = MagicMock()
753833
mock_cm2.__enter__ = MagicMock(return_value=mock_emails_resp)
754834
mock_cm2.__exit__ = MagicMock(return_value=False)
755-
835+
756836
with patch("urllib.request.urlopen", side_effect=[mock_cm1, mock_cm2]):
757837
env = {
758838
"GITHUB_OAUTH_CLIENT_ID": "id",
@@ -766,11 +846,11 @@ def test_fallback_email_when_no_email_found(self):
766846
def test_wechat_does_not_fetch_emails(self):
767847
mock_user_resp = MagicMock()
768848
mock_user_resp.read.return_value = b'{"openid": "wx123", "nickname": "wechat_user"}'
769-
849+
770850
mock_cm = MagicMock()
771851
mock_cm.__enter__ = MagicMock(return_value=mock_user_resp)
772852
mock_cm.__exit__ = MagicMock(return_value=False)
773-
853+
774854
with patch("urllib.request.urlopen", return_value=mock_cm):
775855
env = {
776856
"ENABLE_WECHAT_OAUTH": "true",
@@ -786,11 +866,11 @@ def test_wechat_does_not_fetch_emails(self):
786866
def test_resolves_nested_field_path(self):
787867
mock_user_resp = MagicMock()
788868
mock_user_resp.read.return_value = b'{"attributes": {"userId": "nested123"}, "id": "testuser"}'
789-
869+
790870
mock_cm = MagicMock()
791871
mock_cm.__enter__ = MagicMock(return_value=mock_user_resp)
792872
mock_cm.__exit__ = MagicMock(return_value=False)
793-
873+
794874
with patch("urllib.request.urlopen", return_value=mock_cm):
795875
env = {
796876
"GDE_URL": "https://gde.test",
@@ -807,11 +887,11 @@ class TestExchangeCodeForProviderTokenWithMock(unittest.TestCase):
807887
def test_exchange_with_post_method(self):
808888
mock_token_resp = MagicMock()
809889
mock_token_resp.read.return_value = b'{"access_token": "gh_token_123"}'
810-
890+
811891
mock_cm = MagicMock()
812892
mock_cm.__enter__ = MagicMock(return_value=mock_token_resp)
813893
mock_cm.__exit__ = MagicMock(return_value=False)
814-
894+
815895
with patch("urllib.request.urlopen", return_value=mock_cm):
816896
env = {
817897
"GITHUB_OAUTH_CLIENT_ID": "test_id",
@@ -825,11 +905,11 @@ def test_exchange_with_post_method(self):
825905
def test_exchange_with_get_method(self):
826906
mock_token_resp = MagicMock()
827907
mock_token_resp.read.return_value = b'{"access_token": "wx_token_456", "openid": "wx_openid"}'
828-
908+
829909
mock_cm = MagicMock()
830910
mock_cm.__enter__ = MagicMock(return_value=mock_token_resp)
831911
mock_cm.__exit__ = MagicMock(return_value=False)
832-
912+
833913
with patch("urllib.request.urlopen", return_value=mock_cm):
834914
env = {
835915
"ENABLE_WECHAT_OAUTH": "true",
@@ -845,11 +925,11 @@ def test_exchange_with_get_method(self):
845925
def test_raises_on_provider_error_response(self):
846926
mock_token_resp = MagicMock()
847927
mock_token_resp.read.return_value = b'{"errcode": 40001, "errmsg": "invalid code"}'
848-
928+
849929
mock_cm = MagicMock()
850930
mock_cm.__enter__ = MagicMock(return_value=mock_token_resp)
851931
mock_cm.__exit__ = MagicMock(return_value=False)
852-
932+
853933
with patch("urllib.request.urlopen", return_value=mock_cm):
854934
env = {
855935
"ENABLE_WECHAT_OAUTH": "true",

0 commit comments

Comments
 (0)