Skip to content

Commit 31296f0

Browse files
authored
feat: add GitLab connector (#6529)
1 parent 479d589 commit 31296f0

File tree

10 files changed

+680
-9
lines changed

10 files changed

+680
-9
lines changed

.changeset/fast-adults-dream.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@logto/connector-gitlab": major
3+
---
4+
5+
add GitLab social connector leveraging OAuth2
6+
7+
### Major Changes
8+
9+
- Initial release of the GitLab connector.
10+
11+
This release introduces the Logto connector for GitLab, enabling social sign-in using GitLab accounts. It supports OAuth 2.0 authentication flow, fetching user information, and handling errors gracefully.
12+
13+
### Features
14+
15+
- **OAuth 2.0 Authentication**: Support for OAuth 2.0 authentication flow with GitLab.
16+
- **User Information Retrieval**: Fetches user details such as full name, email, profile URL, and avatar.
17+
- **Error Handling**: Graceful handling of OAuth errors, including token exchange failures and user-denied permissions.
18+
- **Configurable Scope**: Allows customization of OAuth scopes to access different levels of user information.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# GitLab Connector
2+
3+
The official Logto connector for GitLab social sign-in, based on the Hugging Face connector by Silverhand Inc.
4+
5+
**Table of contents**
6+
7+
- [GitLab connector](#gitlab-connector)
8+
- [Get started](#get-started)
9+
- [Sign in with GitLab account](#sign-in-with-gitlab-account)
10+
- [Create and configure OAuth app](#create-and-configure-oauth-app)
11+
- [Managing OAuth apps](#managing-oauth-apps)
12+
- [Configure your connector](#configure-your-connector)
13+
- [Config types](#config-types)
14+
- [Test GitLab connector](#test-gitlab-connector)
15+
- [Reference](#reference)
16+
17+
## Get started
18+
19+
The GitLab connector enables end-users to sign in to your application using their own GitLab accounts via the GitLab OAuth 2.0 authentication protocol.
20+
21+
## Sign in with GitLab account
22+
23+
Go to the [GitLab website](https://gitlab.com/) and sign in with your GitLab account. You may register a new account if you don't have one.
24+
25+
## Create and configure OAuth app
26+
27+
Follow the [creating a GitLab OAuth App](https://docs.gitlab.com/ee/integration/oauth_provider.html) guide, and register a new application.
28+
29+
Name your new OAuth application in **Name** and fill up **Redirect URI** of the app. Customize the **Redirect URIs** as `${your_logto_origin}/callback/${connector_id}`. The `connector_id` can be found on the top bar of the Logto Admin Console connector details page.
30+
31+
On scopes, select `openid`. You also may want to enable `profile`, and `email`. `profile` scope is required to get the user's profile information, and `email` scope is required to get the user's email address. Ensure you have allowed these scopes in your GitLab OAuth app if you want to use them.
32+
33+
> Notes:
34+
> * If you use custom domains, add both the custom domain and the default Logto domain to the Redirect URIs to ensure the OAuth flow works correctly with both domains.
35+
> * If you encounter the error message "The redirect_uri MUST match the registered callback URL for this application." when logging in, try aligning the Redirect URI of your GitLab OAuth App and your Logto App's redirect URL (including the protocol) to resolve the issue.
36+
37+
## Managing OAuth apps
38+
39+
Go to the [Applications page](https://gitlab.com/-/profile/applications) on GitLab, where you can add, edit, or delete existing OAuth apps. You can also find the `Application ID` and generate `Secret` in the OAuth app detail pages.
40+
41+
## Configure your connector
42+
43+
Fill out the `clientId` and `clientSecret` field with the _Application ID_ and _Secret_ you've got from the OAuth app detail pages mentioned in the previous section.
44+
45+
`scope` is a space-delimited list of [scopes](https://docs.gitlab.com/ee/integration/oauth_provider.html#authorized-applications). If not provided, scope defaults to be `openid`. For GitLab connector, the scope you may want to use are `openid`, `profile` and `email`. `profile` scope is required to get the user's profile information, and `email` scope is required to get the user's email address. Ensure you have allowed these scopes in your GitLab OAuth app (configured in [Create an OAuth app in the Hugging Face](#create-and-configure-oauth-app) section).
46+
47+
### Config types
48+
49+
| Name | Type |
50+
|--------------|--------|
51+
| clientId | string |
52+
| clientSecret | string |
53+
| scope | string |
54+
55+
## Test GitLab connector
56+
57+
That's it. The GitLab connector should be available now. Don't forget to [Enable connector in sign-in experience](https://docs.logto.io/docs/recipes/configure-connectors/social-connector/enable-social-sign-in/).
58+
59+
## Reference
60+
61+
- [GitLab - API Documentation](https://docs.gitlab.com/ee/api/)
62+
- [GitLab - OAuth Applications](https://docs.gitlab.com/ee/integration/oauth_provider.html)
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"name": "@logto/connector-gitlab",
3+
"version": "0.0.1",
4+
"description": "GitLab social connector implementation.",
5+
"dependencies": {
6+
"@logto/connector-kit": "workspace:^4.0.0",
7+
"@logto/connector-oauth": "workspace:^1.4.0",
8+
"@logto/shared": "workspace:^3.1.1",
9+
"@silverhand/essentials": "^2.9.1",
10+
"jose": "^5.6.3",
11+
"ky": "^1.2.3",
12+
"nanoid": "^5.0.1",
13+
"snakecase-keys": "^8.0.1",
14+
"zod": "^3.23.8"
15+
},
16+
"main": "./lib/index.js",
17+
"module": "./lib/index.js",
18+
"exports": "./lib/index.js",
19+
"license": "MPL-2.0",
20+
"type": "module",
21+
"files": [
22+
"lib",
23+
"docs",
24+
"logo.svg",
25+
"logo-dark.svg"
26+
],
27+
"scripts": {
28+
"precommit": "lint-staged",
29+
"check": "tsc --noEmit",
30+
"build": "tsup",
31+
"dev": "tsup --watch",
32+
"lint": "eslint --ext .ts src",
33+
"lint:report": "pnpm lint --format json --output-file report.json",
34+
"test": "vitest src",
35+
"test:ci": "pnpm run test --silent --coverage",
36+
"prepublishOnly": "pnpm build"
37+
},
38+
"engines": {
39+
"node": "^20.9.0"
40+
},
41+
"eslintConfig": {
42+
"extends": "@silverhand",
43+
"settings": {
44+
"import/core-modules": [
45+
"@silverhand/essentials",
46+
"got",
47+
"nock",
48+
"snakecase-keys",
49+
"zod"
50+
]
51+
}
52+
},
53+
"prettier": "@silverhand/eslint-config/.prettierrc",
54+
"publishConfig": {
55+
"access": "public"
56+
},
57+
"devDependencies": {
58+
"@silverhand/eslint-config": "6.0.1",
59+
"@silverhand/ts-config": "6.0.0",
60+
"@types/node": "^20.11.20",
61+
"@types/supertest": "^6.0.2",
62+
"@vitest/coverage-v8": "^2.0.0",
63+
"eslint": "^8.56.0",
64+
"lint-staged": "^15.0.2",
65+
"nock": "14.0.0-beta.9",
66+
"prettier": "^3.0.0",
67+
"supertest": "^7.0.0",
68+
"tsup": "^8.1.0",
69+
"typescript": "^5.5.3",
70+
"vitest": "^2.0.0"
71+
}
72+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { ConnectorMetadata } from '@logto/connector-kit';
2+
import { ConnectorPlatform } from '@logto/connector-kit';
3+
import { clientSecretFormItem, clientIdFormItem, scopeFormItem } from '@logto/connector-oauth';
4+
5+
export const jwksUri = 'https://gitlab.com/oauth/discovery/keys';
6+
export const authorizationEndpoint = 'https://gitlab.com/oauth/authorize';
7+
export const userInfoEndpoint = 'https://gitlab.com/oauth/userinfo';
8+
export const tokenEndpoint = 'https://gitlab.com/oauth/token';
9+
export const mandatoryScope = 'openid'; // Always required
10+
export const defaultScopes = [mandatoryScope];
11+
12+
export const defaultMetadata: ConnectorMetadata = {
13+
id: 'gitlab-universal',
14+
target: 'gitlab',
15+
platform: ConnectorPlatform.Universal,
16+
name: {
17+
en: 'GitLab',
18+
},
19+
logo: './logo.svg',
20+
logoDark: null,
21+
description: {
22+
en: 'GitLab is an online community for software development and version control.',
23+
},
24+
readme: './README.md',
25+
formItems: [
26+
clientIdFormItem,
27+
clientSecretFormItem,
28+
{
29+
...scopeFormItem,
30+
description:
31+
"`openid` is required to allow OIDC and it's always added to the scopes if not present, `profile` is required to get user's profile information and `email` is required to get user's email address. These scopes can be used individually or in combination; if no scopes are specified, `openid` will be used by default.",
32+
},
33+
],
34+
};
35+
36+
export const defaultTimeout = 5000;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import nock from 'nock';
2+
3+
import { ConnectorError, ConnectorErrorCodes, type SocialUserInfo } from '@logto/connector-kit';
4+
5+
import { authorizationEndpoint, tokenEndpoint, userInfoEndpoint } from './constant.js';
6+
import createConnector from './index.js';
7+
8+
const getConfig = vi.fn().mockResolvedValue({
9+
clientId: '<client-id>',
10+
clientSecret: '<client-secret>',
11+
scope: 'profile email',
12+
});
13+
14+
const getSessionMock = vi.fn().mockResolvedValue({ redirectUri: 'http://localhost:3000/callback' });
15+
16+
describe('GitLab connector', () => {
17+
beforeEach(() => {
18+
nock(tokenEndpoint).post('').reply(200, {
19+
access_token: 'access_token',
20+
scope: 'scope',
21+
token_type: 'token_type',
22+
});
23+
});
24+
25+
afterEach(() => {
26+
nock.cleanAll();
27+
vi.clearAllMocks();
28+
});
29+
30+
it('should get a valid uri by redirectUri and state', async () => {
31+
const connector = await createConnector({ getConfig });
32+
const authorizationUri = await connector.getAuthorizationUri(
33+
{
34+
state: 'some_state',
35+
redirectUri: 'http://localhost:3000/callback',
36+
connectorId: 'some_connector_id',
37+
connectorFactoryId: 'some_connector_factory_id',
38+
jti: 'some_jti',
39+
headers: {},
40+
},
41+
vi.fn()
42+
);
43+
44+
expect(authorizationUri).toEqual(
45+
`${authorizationEndpoint}?${new URLSearchParams({
46+
response_type: 'code',
47+
client_id: '<client-id>',
48+
scope: 'profile email openid', // We add openid to the scopes if not present always
49+
redirect_uri: 'http://localhost:3000/callback',
50+
state: 'some_state',
51+
}).toString()}`
52+
);
53+
});
54+
55+
it('should get valid SocialUserInfo', async () => {
56+
nock(userInfoEndpoint)
57+
.get('')
58+
.reply(200, {
59+
sub: '1234567',
60+
sub_legacy: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
61+
name: 'John Doe',
62+
nickname: 'john.doe',
63+
preferred_username: 'john.doe',
64+
email: 'john.doe@example.com',
65+
email_verified: true,
66+
profile: 'https://gitlab.com/john.doe',
67+
picture: 'https://gitlab.com/uploads/-/system/user/avatar/1234567/avatar.png',
68+
groups: ['group1', 'group2', 'group3'],
69+
});
70+
71+
const connector = await createConnector({ getConfig });
72+
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, getSessionMock);
73+
const expectedSocialUserInfo: SocialUserInfo = {
74+
id: '1234567',
75+
avatar: 'https://gitlab.com/uploads/-/system/user/avatar/1234567/avatar.png',
76+
name: 'John Doe',
77+
email: 'john.doe@example.com',
78+
rawData: {
79+
sub: '1234567',
80+
sub_legacy: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
81+
name: 'John Doe',
82+
nickname: 'john.doe',
83+
preferred_username: 'john.doe',
84+
email: 'john.doe@example.com',
85+
email_verified: true,
86+
profile: 'https://gitlab.com/john.doe',
87+
picture: 'https://gitlab.com/uploads/-/system/user/avatar/1234567/avatar.png',
88+
groups: ['group1', 'group2', 'group3'],
89+
},
90+
};
91+
92+
expect(socialUserInfo).toStrictEqual(expectedSocialUserInfo);
93+
});
94+
95+
it('should not return email when email_verified is false on SocialUserInfo', async () => {
96+
nock(userInfoEndpoint)
97+
.get('')
98+
.reply(200, {
99+
sub: '1234567',
100+
sub_legacy: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
101+
name: 'John Doe',
102+
nickname: 'john.doe',
103+
preferred_username: 'john.doe',
104+
email: 'john.doe@example.com',
105+
email_verified: false,
106+
profile: 'https://gitlab.com/john.doe',
107+
picture: 'https://gitlab.com/uploads/-/system/user/avatar/1234567/avatar.png',
108+
groups: ['group1', 'group2', 'group3'],
109+
});
110+
111+
const connector = await createConnector({ getConfig });
112+
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, getSessionMock);
113+
const expectedSocialUserInfo: SocialUserInfo = {
114+
id: '1234567',
115+
avatar: 'https://gitlab.com/uploads/-/system/user/avatar/1234567/avatar.png',
116+
name: 'John Doe',
117+
rawData: {
118+
sub: '1234567',
119+
sub_legacy: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
120+
name: 'John Doe',
121+
nickname: 'john.doe',
122+
preferred_username: 'john.doe',
123+
email: 'john.doe@example.com',
124+
email_verified: false,
125+
profile: 'https://gitlab.com/john.doe',
126+
picture: 'https://gitlab.com/uploads/-/system/user/avatar/1234567/avatar.png',
127+
groups: ['group1', 'group2', 'group3'],
128+
},
129+
};
130+
131+
expect(socialUserInfo).toStrictEqual(expectedSocialUserInfo);
132+
});
133+
134+
it('throws AuthorizationFailed error if authentication failed', async () => {
135+
const connector = await createConnector({ getConfig });
136+
await expect(
137+
connector.getUserInfo({ error: 'some error' }, getSessionMock)
138+
).rejects.toStrictEqual(
139+
new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, { error: 'some error' })
140+
);
141+
});
142+
143+
it('throws InvalidResponse error if token response is invalid', async () => {
144+
// Clear token response mock
145+
nock.cleanAll();
146+
147+
nock(tokenEndpoint).post('').reply(200, {
148+
invalid_field: true,
149+
});
150+
151+
const connector = await createConnector({ getConfig });
152+
await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toSatisfy(
153+
(connectorError) =>
154+
(connectorError as ConnectorError).code === ConnectorErrorCodes.InvalidResponse
155+
);
156+
});
157+
158+
it('throws InvalidResponse error if userinfo response is invalid', async () => {
159+
nock(userInfoEndpoint).get('').reply(200, {
160+
id: 'id',
161+
});
162+
163+
const connector = await createConnector({ getConfig });
164+
await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toSatisfy(
165+
(connectorError) =>
166+
(connectorError as ConnectorError).code === ConnectorErrorCodes.InvalidResponse
167+
);
168+
});
169+
170+
it('throws SocialAccessTokenInvalid error if user info responded with 401', async () => {
171+
nock(userInfoEndpoint).get('').reply(401);
172+
173+
const connector = await createConnector({ getConfig });
174+
await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toStrictEqual(
175+
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
176+
);
177+
});
178+
179+
it('throws General error if user info responded with a non-401 error', async () => {
180+
nock(userInfoEndpoint).get('').reply(422);
181+
182+
const connector = await createConnector({ getConfig });
183+
await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toStrictEqual(
184+
new ConnectorError(ConnectorErrorCodes.General)
185+
);
186+
});
187+
});

0 commit comments

Comments
 (0)