Skip to content

Commit 0b2a0b4

Browse files
authored
feat(gitlab): added support for on-premise gitlab (#145)
* fix(gitlab): added support for on-premise gitlab * fix(gitlab): updated first/last name parsing, fixed style * test(openid_response): allow testing of multiple cases per provider * fix(gitlab): handle the edge case if the gitlab name contains invalid data
1 parent 0062371 commit 0b2a0b4

File tree

3 files changed

+157
-21
lines changed

3 files changed

+157
-21
lines changed

examples/gitlab.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88

99
CLIENT_ID = os.environ["CLIENT_ID"]
1010
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
11+
BASE_ENDPOINT_URL = os.environ.get("GITLAB_ENDPOINT_URL", "https://gitlab.com")
1112

1213
app = FastAPI()
1314

1415
sso = GitlabSSO(
1516
client_id=CLIENT_ID,
1617
client_secret=CLIENT_SECRET,
18+
base_endpoint_url=BASE_ENDPOINT_URL,
1719
redirect_uri="http://localhost:5000/auth/callback",
1820
allow_insecure_http=True,
1921
)

fastapi_sso/sso/gitlab.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Gitlab SSO Oauth Helper class"""
22

3-
from typing import TYPE_CHECKING, Optional
3+
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
4+
from urllib.parse import urljoin
5+
6+
import pydantic
47

58
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
69

@@ -14,19 +17,61 @@ class GitlabSSO(SSOBase):
1417
provider = "gitlab"
1518
scope = ["read_user", "openid", "profile"]
1619
additional_headers = {"accept": "application/json"}
20+
base_endpoint_url = "https://gitlab.com"
21+
22+
def __init__(
23+
self,
24+
client_id: str,
25+
client_secret: str,
26+
redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None,
27+
allow_insecure_http: bool = False,
28+
use_state: bool = False, # TODO: Remove use_state argument
29+
scope: Optional[List[str]] = None,
30+
base_endpoint_url: Optional[str] = None,
31+
) -> None:
32+
super().__init__(
33+
client_id,
34+
client_secret,
35+
redirect_uri,
36+
allow_insecure_http,
37+
use_state, # TODO: Remove use_state argument
38+
scope,
39+
)
40+
self.base_endpoint_url = base_endpoint_url or self.base_endpoint_url
1741

1842
async def get_discovery_document(self) -> DiscoveryDocument:
43+
"""Override the discovery document method to return Yandex OAuth endpoints."""
44+
1945
return {
20-
"authorization_endpoint": "https://gitlab.com/oauth/authorize",
21-
"token_endpoint": "https://gitlab.com/oauth/token",
22-
"userinfo_endpoint": "https://gitlab.com/api/v4/user",
46+
"authorization_endpoint": urljoin(self.base_endpoint_url, "/oauth/authorize"),
47+
"token_endpoint": urljoin(self.base_endpoint_url, "/oauth/token"),
48+
"userinfo_endpoint": urljoin(self.base_endpoint_url, "/api/v4/user"),
2349
}
2450

51+
def _parse_name(self, full_name: Optional[str]) -> Tuple[Union[str, None], Union[str, None]]:
52+
"""Parses the full name from Gitlab into the first and last name."""
53+
if not full_name or not isinstance(full_name, str):
54+
return None, None
55+
56+
name_parts = full_name.split()
57+
58+
if len(name_parts) == 1:
59+
return name_parts[0], None
60+
61+
first_name = name_parts[0]
62+
last_name = " ".join(name_parts[1:])
63+
return first_name, last_name
64+
2565
async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
66+
"""Converts Gitlab user info response to OpenID object."""
67+
first_name, last_name = self._parse_name(response.get("name"))
68+
2669
return OpenID(
2770
email=response["email"],
2871
provider=self.provider,
2972
id=str(response["id"]),
73+
first_name=first_name,
74+
last_name=last_name,
3075
display_name=response["username"],
3176
picture=response["avatar_url"],
3277
)

tests/test_openid_responses.py

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,22 @@
1515
from fastapi_sso.sso.facebook import FacebookSSO
1616
from fastapi_sso.sso.yandex import YandexSSO
1717

18-
sso_mapping: Dict[Type[SSOBase], Tuple[Dict[str, Any], OpenID]] = {
19-
TwitterSSO: (
18+
sso_test_cases: Tuple[Type[SSOBase], Tuple[Dict[str, Any], OpenID]] = (
19+
(
20+
TwitterSSO,
2021
{"data": {"id": "test", "username": "TestUser1234", "name": "Test User"}},
2122
OpenID(id="test", display_name="TestUser1234", first_name="Test", last_name="User", provider="twitter"),
2223
),
23-
SpotifySSO: (
24+
(
25+
SpotifySSO,
2426
{"email": "test@example.com", "display_name": "testuser", "id": "test", "images": [{"url": "https://myimage"}]},
2527
OpenID(
2628
id="test", provider="spotify", display_name="testuser", email="test@example.com", picture="https://myimage"
2729
),
2830
),
29-
NaverSSO: ({"properties": {"nickname": "test"}}, OpenID(display_name="test", provider="naver")),
30-
MicrosoftSSO: (
31+
(NaverSSO, {"properties": {"nickname": "test"}}, OpenID(display_name="test", provider="naver")),
32+
(
33+
MicrosoftSSO,
3134
{"mail": "test@example.com", "displayName": "Test User", "id": "test", "givenName": "Test", "surname": "User"},
3235
OpenID(
3336
email="test@example.com",
@@ -38,7 +41,8 @@
3841
last_name="User",
3942
),
4043
),
41-
LinkedInSSO: (
44+
(
45+
LinkedInSSO,
4246
{
4347
"email": "test@example.com",
4448
"sub": "test",
@@ -55,30 +59,116 @@
5559
picture="https://myimage",
5660
),
5761
),
58-
LineSSO: (
62+
(
63+
LineSSO,
5964
{"email": "test@example.com", "name": "Test User", "sub": "test", "picture": "https://myimage"},
6065
OpenID(
6166
email="test@example.com", display_name="Test User", id="test", picture="https://myimage", provider="line"
6267
),
6368
),
64-
KakaoSSO: ({"properties": {"nickname": "Test User"}}, OpenID(provider="kakao", display_name="Test User")),
65-
GitlabSSO: (
69+
(KakaoSSO, {"properties": {"nickname": "Test User"}}, OpenID(provider="kakao", display_name="Test User")),
70+
(
71+
# Gitlab Case 1: full name is empty
72+
GitlabSSO,
6673
{"email": "test@example.com", "id": "test", "username": "test_user", "avatar_url": "https://myimage"},
6774
OpenID(
6875
email="test@example.com", id="test", display_name="test_user", picture="https://myimage", provider="gitlab"
6976
),
7077
),
71-
GithubSSO: (
78+
(
79+
# Gitlab Case 2: full name contains only first name
80+
GitlabSSO,
81+
{
82+
"email": "test@example.com",
83+
"id": "test",
84+
"username": "test_user",
85+
"avatar_url": "https://myimage",
86+
"name": "Test",
87+
},
88+
OpenID(
89+
email="test@example.com",
90+
id="test",
91+
display_name="test_user",
92+
picture="https://myimage",
93+
first_name="Test",
94+
last_name=None,
95+
provider="gitlab",
96+
),
97+
),
98+
(
99+
# Gitlab Case 3: full name contains long last name
100+
GitlabSSO,
101+
{
102+
"email": "test@example.com",
103+
"id": "test",
104+
"username": "test_user",
105+
"avatar_url": "https://myimage",
106+
"name": "Test User Long Last Name",
107+
},
108+
OpenID(
109+
email="test@example.com",
110+
id="test",
111+
display_name="test_user",
112+
picture="https://myimage",
113+
first_name="Test",
114+
last_name="User Long Last Name",
115+
provider="gitlab",
116+
),
117+
),
118+
(
119+
# Gitlab Case 4: full name contains standard first and last names
120+
GitlabSSO,
121+
{
122+
"email": "test@example.com",
123+
"id": "test",
124+
"username": "test_user",
125+
"avatar_url": "https://myimage",
126+
"name": "Test User",
127+
},
128+
OpenID(
129+
email="test@example.com",
130+
id="test",
131+
display_name="test_user",
132+
picture="https://myimage",
133+
first_name="Test",
134+
last_name="User",
135+
provider="gitlab",
136+
),
137+
),
138+
(
139+
# Gitlab Case 5: full name contains invalid type or data
140+
GitlabSSO,
141+
{
142+
"email": "test@example.com",
143+
"id": "test",
144+
"username": "test_user",
145+
"avatar_url": "https://myimage",
146+
"name": {"invalid": 1},
147+
},
148+
OpenID(
149+
email="test@example.com",
150+
id="test",
151+
display_name="test_user",
152+
picture="https://myimage",
153+
first_name=None,
154+
last_name=None,
155+
provider="gitlab",
156+
),
157+
),
158+
(
159+
GithubSSO,
72160
{"email": "test@example.com", "id": "test", "login": "testuser", "avatar_url": "https://myimage"},
73161
OpenID(
74162
email="test@example.com", id="test", display_name="testuser", picture="https://myimage", provider="github"
75163
),
76164
),
77-
FitbitSSO: (
165+
(
166+
FitbitSSO,
78167
{"user": {"encodedId": "test", "fullName": "Test", "displayName": "Test User", "avatar": "https://myimage"}},
79168
OpenID(id="test", first_name="Test", display_name="Test User", provider="fitbit", picture="https://myimage"),
80169
),
81-
FacebookSSO: (
170+
(
171+
FacebookSSO,
82172
{
83173
"email": "test@example.com",
84174
"first_name": "Test",
@@ -97,7 +187,8 @@
97187
picture="https://myimage",
98188
),
99189
),
100-
YandexSSO: (
190+
(
191+
YandexSSO,
101192
{
102193
"id": "test",
103194
"display_name": "test",
@@ -116,12 +207,10 @@
116207
picture="https://avatars.yandex.net/get-yapic/123456/islands-200",
117208
),
118209
),
119-
}
210+
)
120211

121212

122-
@pytest.mark.parametrize(
123-
("ProviderClass", "response", "openid"), [(key, value[0], value[1]) for key, value in sso_mapping.items()]
124-
)
213+
@pytest.mark.parametrize(("ProviderClass", "response", "openid"), sso_test_cases)
125214
async def test_provider_openid_by_response(
126215
ProviderClass: Type[SSOBase], response: Dict[str, Any], openid: OpenID
127216
) -> None:

0 commit comments

Comments
 (0)