0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat: add Patreon connector (#6514)

This commit is contained in:
DevTekVE 2024-08-28 07:00:24 +02:00 committed by GitHub
parent 8beb758771
commit aba089285b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 644 additions and 0 deletions

View file

@ -0,0 +1,18 @@
---
"@logto/connector-patreon": major
---
add Patreon social connector
### Major Changes
- Initial release of the Patreon connector.
This release introduces the Logto connector for Patreon, enabling social sign-in using Patreon accounts. It supports OAuth 2.0 authentication flow, fetching user information, and handling errors gracefully.
### Features
- **OAuth 2.0 Authentication**: Support for OAuth 2.0 authentication flow with Patreon.
- **User Information Retrieval**: Fetches user details such as full name, email, profile URL, and avatar.
- **Error Handling**: Graceful handling of OAuth errors, including token exchange failures and user-denied permissions.
- **Configurable Scope**: Allows customization of OAuth scopes to access different levels of user information.

View file

@ -0,0 +1,60 @@
# Patreon Connector
The official Logto connector for Patreon social sign-in, based on the Hugging Face connector by Silverhand Inc.
**Table of contents**
- [Patreon connector](#patreon-connector)
- [Get started](#get-started)
- [Sign in with Patreon account](#sign-in-with-patreon-account)
- [Create and configure OAuth app](#create-and-configure-oauth-app)
- [Managing OAuth apps](#managing-oauth-apps)
- [Configure your connector](#configure-your-connector)
- [Config types](#config-types)
- [Test Patreon connector](#test-patreon-connector)
- [Reference](#reference)
## Get started
The Patreon connector enables end-users to sign in to your application using their own Patreon accounts via the Patreon OAuth 2.0 authentication protocol. This connector is adapted from the Hugging Face connector by Silverhand Inc., leveraging many of the same implementation patterns and configurations.
## Sign in with Patreon account
Go to the [Patreon website](https://www.patreon.com/) and sign in with your Patreon account. You may register a new account if you don't have one.
## Create and configure OAuth app
Follow the [creating a Patreon OAuth App](https://www.patreon.com/portal/registration/register-clients) guide, and register a new application.
Name your new OAuth application in **App Name** and fill up **App URL** of the app. You can leave the **App Description** field blank and 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.
> Note: 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 Patreon OAuth App and your Logto App's redirect URL (including the protocol) to resolve the issue.
## Managing OAuth apps
Go to the [Clients & API Keys page](https://www.patreon.com/portal/registration/register-clients) on Patreon, where you can add, edit, or delete existing OAuth apps. You can also find the `Client ID` and generate `Client secrets` in the OAuth app detail pages.
## Configure your connector
Fill out the `clientId` and `clientSecret` field with the _Client ID_ and _Client Secret_ you've got from the OAuth app detail pages mentioned in the previous section.
`scope` is a space-delimited list of [scopes](https://docs.patreon.com/#scopes). If not provided, the scope defaults to `identity identity[email]`.
### Config types
| Name | Type |
|--------------|--------|
| clientId | string |
| clientSecret | string |
| scope | string |
## Test Patreon connector
That's it. The Patreon 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/).
## Reference
- [Patreon - API Documentation](https://docs.patreon.com/)
- [Patreon - Developers - Clients](https://www.patreon.com/portal/registration/register-clients)
---

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.4.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1080 1080" style="enable-background:new 0 0 1080 1080;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path class="st0" d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2
C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33
c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/>
</svg>

After

Width:  |  Height:  |  Size: 735 B

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.7.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1080 1080" style="enable-background:new 0 0 1080 1080;" xml:space="preserve">
<path d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2
C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33
c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/>
</svg>

After

Width:  |  Height:  |  Size: 669 B

View file

@ -0,0 +1,69 @@
{
"name": "@logto/connector-patreon",
"version": "0.0.1",
"description": "Patreon web connector implementation.",
"author": "DevTekVE <devtekve@gmail.com> & Silverhand Inc. <contact@silverhand.io>",
"dependencies": {
"@logto/connector-kit": "workspace:^4.0.0",
"@logto/connector-oauth": "workspace:^1.4.0",
"@silverhand/essentials": "^2.9.1",
"ky": "^1.2.3",
"zod": "^3.23.8"
},
"main": "./lib/index.js",
"module": "./lib/index.js",
"exports": "./lib/index.js",
"license": "MPL-2.0",
"type": "module",
"files": [
"lib",
"docs",
"logo.svg",
"logo-dark.svg"
],
"scripts": {
"precommit": "lint-staged",
"check": "tsc --noEmit",
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"test": "vitest src",
"test:ci": "pnpm run test --silent --coverage",
"prepublishOnly": "pnpm build"
},
"engines": {
"node": "^20.9.0"
},
"eslintConfig": {
"extends": "@silverhand",
"settings": {
"import/core-modules": [
"@silverhand/essentials",
"got",
"nock",
"snakecase-keys",
"zod"
]
}
},
"prettier": "@silverhand/eslint-config/.prettierrc",
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@silverhand/eslint-config": "6.0.1",
"@silverhand/ts-config": "6.0.0",
"@types/node": "^20.11.20",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^2.0.0",
"eslint": "^8.56.0",
"lint-staged": "^15.0.2",
"nock": "14.0.0-beta.9",
"prettier": "^3.0.0",
"supertest": "^7.0.0",
"tsup": "^8.1.0",
"typescript": "^5.5.3",
"vitest": "^2.0.0"
}
}

View file

@ -0,0 +1,37 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorPlatform } from '@logto/connector-kit';
import { clientIdFormItem, clientSecretFormItem, scopeFormItem } from '@logto/connector-oauth';
export const authorizationEndpoint = 'https://www.patreon.com/oauth2/authorize';
export const scope = 'identity identity[email]';
export const tokenEndpoint = 'https://www.patreon.com/api/oauth2/token';
export const userInfoEndpoint = 'https://www.patreon.com/api/oauth2/api/current_user';
export const defaultMetadata: ConnectorMetadata = {
id: 'patreon-universal',
target: 'patreon',
platform: ConnectorPlatform.Universal,
name: {
en: 'Patreon',
'zh-CN': 'Patreon',
'tr-TR': 'Patreon',
ko: 'Patreon',
},
logo: './logo.svg',
logoDark: './logo-dark.svg',
description: {
en: 'Patreon is a membership platform that makes it easy for artists and creators to get paid.',
},
readme: './README.md',
formItems: [
clientIdFormItem,
clientSecretFormItem,
{
...scopeFormItem,
description:
"The `scope` determines permissions granted by the user's authorization. If you are not sure what to enter, do not worry, just leave it blank.",
},
],
};
export const defaultTimeout = 5000;

View file

@ -0,0 +1,155 @@
import nock from 'nock';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { authorizationEndpoint, tokenEndpoint, userInfoEndpoint } from './constant.js';
import createConnector from './index.js';
const getConfig = vi.fn().mockResolvedValue({
clientId: '<client-id>',
clientSecret: '<client-secret>',
scope: 'profile email',
});
const getSessionMock = vi.fn().mockResolvedValue({ redirectUri: 'http://localhost:3000/callback' });
describe('Patreon connector', () => {
beforeEach(() => {
nock(tokenEndpoint).post('').reply(200, {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
});
});
afterEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should get a valid uri by redirectUri and state', async () => {
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri(
{
state: 'some_state',
redirectUri: 'http://localhost:3000/callback',
connectorId: 'some_connector_id',
connectorFactoryId: 'some_connector_factory_id',
jti: 'some_jti',
headers: {},
},
vi.fn()
);
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?${new URLSearchParams({
response_type: 'code',
client_id: '<client-id>',
scope: 'profile email',
redirect_uri: 'http://localhost:3000/callback',
state: 'some_state',
}).toString()}`
);
});
it('should get valid SocialUserInfo', async () => {
nock(userInfoEndpoint)
.get('')
.reply(200, {
data: {
id: '12345',
attributes: {
full_name: 'Jane Doe',
vanity: 'janedoe',
url: 'https://www.patreon.com/janedoe',
image_url: 'https://c10.patreon.com/2/400/12345',
email: 'janedoe@example.com',
is_email_verified: true,
created: '2020-01-01T12:00:00Z',
},
},
});
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, getSessionMock);
expect(socialUserInfo).toStrictEqual({
id: '12345',
avatar: 'https://c10.patreon.com/2/400/12345',
name: 'Jane Doe',
email: 'janedoe@example.com',
email_verified: true,
profile: 'https://www.patreon.com/janedoe',
preferred_username: 'janedoe',
website: 'https://www.patreon.com/janedoe',
rawData: {
data: {
id: '12345',
attributes: {
full_name: 'Jane Doe',
vanity: 'janedoe',
url: 'https://www.patreon.com/janedoe',
image_url: 'https://c10.patreon.com/2/400/12345',
email: 'janedoe@example.com',
is_email_verified: true,
created: '2020-01-01T12:00:00Z',
},
},
},
});
});
it('throws AuthorizationFailed error if authentication failed', async () => {
const connector = await createConnector({ getConfig });
await expect(
connector.getUserInfo({ error: 'some error' }, getSessionMock)
).rejects.toStrictEqual(
new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, { error: 'some error' })
);
});
it('throws InvalidResponse error if token response is invalid', async () => {
// Clear token response mock
nock.cleanAll();
nock(tokenEndpoint).post('').reply(200, {
invalid_field: true,
});
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toSatisfy(
(connectorError) =>
(connectorError as ConnectorError).code === ConnectorErrorCodes.InvalidResponse
);
});
it('throws InvalidResponse error if userinfo response is invalid', async () => {
nock(userInfoEndpoint).get('').reply(200, {
id: 'id',
});
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toSatisfy(
(connectorError) =>
(connectorError as ConnectorError).code === ConnectorErrorCodes.InvalidResponse
);
});
it('throws SocialAccessTokenInvalid error if user info responded with 401', async () => {
nock(userInfoEndpoint).get('').reply(401);
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toStrictEqual(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
it('throws General error if user info responded with a non-401 error', async () => {
nock(userInfoEndpoint).get('').reply(422);
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toStrictEqual(
new ConnectorError(ConnectorErrorCodes.General)
);
});
});

View file

@ -0,0 +1,178 @@
import { assert, conditional } from '@silverhand/essentials';
import type {
GetAuthorizationUri,
GetUserInfo,
SocialConnector,
CreateConnector,
GetConnectorConfig,
} from '@logto/connector-kit';
import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
parseJson,
} from '@logto/connector-kit';
import {
constructAuthorizationUri,
oauth2AuthResponseGuard,
requestTokenEndpoint,
TokenEndpointAuthMethod,
} from '@logto/connector-oauth';
import ky, { HTTPError } from 'ky';
import {
authorizationEndpoint,
scope as defaultScope,
userInfoEndpoint,
defaultMetadata,
defaultTimeout,
tokenEndpoint,
} from './constant.js';
import { accessTokenResponseGuard, patreonConfigGuard, userInfoResponseGuard } from './types.js';
/**
* Generates an authorization URI for the given configuration.
*
* @param {GetConnectorConfig} getConfig - Function to retrieve the connector configuration.
* @return {GetAuthorizationUri} - A function that generates the authorization URI.
*/
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }, setSession) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, patreonConfigGuard);
const { clientId, scope } = config;
await setSession({ redirectUri });
return constructAuthorizationUri(authorizationEndpoint, {
responseType: 'code',
clientId,
scope: scope ?? defaultScope, // Defaults to 'profile' if not provided
redirectUri,
state,
});
};
/**
* Asynchronously fetches user information from a social media provider.
*
* @param {GetConnectorConfig} getConfig - A function to get the connector configuration.
*
* @returns {GetUserInfo} - A function that retrieves user information.
*
* @throws {ConnectorError} - If an error occurs during the retrieval of user information.
*/
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data, getSession) => {
const authResponseResult = oauth2AuthResponseGuard.safeParse(data);
if (!authResponseResult.success) {
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, data);
}
const { code } = authResponseResult.data;
const config = await getConfig(defaultMetadata.id);
validateConfig(config, patreonConfigGuard);
const { clientId, clientSecret } = config;
const { redirectUri } = await getSession();
if (!redirectUri) {
throw new ConnectorError(ConnectorErrorCodes.General, {
message: 'Cannot find `redirectUri` from connector session.',
});
}
const tokenResponse = await requestTokenEndpoint({
tokenEndpoint,
tokenEndpointAuthOptions: {
method: TokenEndpointAuthMethod.ClientSecretBasic,
},
tokenRequestBody: {
grantType: 'authorization_code',
code,
redirectUri,
clientId,
clientSecret,
},
});
const parsedTokenResponse = accessTokenResponseGuard.safeParse(await tokenResponse.json());
if (!parsedTokenResponse.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, parsedTokenResponse.error);
}
const { access_token: accessToken, token_type: tokenType } = parsedTokenResponse.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
try {
const userInfoResponse = await ky.get(userInfoEndpoint, {
headers: {
authorization: `${tokenType} ${accessToken}`,
},
timeout: defaultTimeout,
});
const rawData = parseJson(await userInfoResponse.text());
const parsedUserInfoResponse = userInfoResponseGuard.safeParse(rawData);
if (!parsedUserInfoResponse.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, parsedUserInfoResponse.error);
}
const { data } = parsedUserInfoResponse.data;
const { attributes } = data;
return {
id: data.id,
avatar: conditional(attributes.image_url),
email: conditional(attributes.email),
name: conditional(attributes.full_name),
rawData,
email_verified: conditional(attributes.is_email_verified), // Direct email verification info
profile: attributes.url,
preferred_username: attributes.vanity,
website: attributes.url,
};
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { response } = error;
if (response.status === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, await response.text());
}
throw error;
}
};
/**
* Creates a Patreon connector.
*
* @param {Object} options - Options for creating the connector.
* @param {Function} options.getConfig - Function used to get configuration options.
* @returns {Object} - Patreon connector object.
*/
const createPatreonConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: patreonConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
export default createPatreonConnector;

View file

@ -0,0 +1,4 @@
export const mockedConfig = {
clientId: '<client-id>',
clientSecret: '<client-secret>',
};

View file

@ -0,0 +1,46 @@
import { z } from 'zod';
// Define a configuration guard for Patreon
export const patreonConfigGuard = z.object({
clientId: z.string(),
clientSecret: z.string(),
scope: z.string().optional(),
});
export type PatreonConfig = z.infer<typeof patreonConfigGuard>;
// Define a guard for validating the access token response
export const accessTokenResponseGuard = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number().optional(),
refresh_token: z.string().optional(),
scope: z.string(),
});
export type AccessTokenResponse = z.infer<typeof accessTokenResponseGuard>;
// Define a guard for validating the Patreon user info response
export const userInfoResponseGuard = z.object({
data: z.object({
id: z.string(),
attributes: z.object({
full_name: z.string().optional().nullable(),
vanity: z.string().optional().nullable(),
url: z.string().optional().nullable(),
image_url: z.string().optional().nullable(),
email: z.string().optional().nullable(),
is_email_verified: z.boolean().optional(),
}),
}),
});
export type PatreonUserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export const authorizationCallbackErrorGuard = z.object({
error: z.string(),
error_description: z.string(),
error_uri: z.string(),
});
export const authResponseGuard = z.object({ code: z.string() });

View file

@ -1842,6 +1842,64 @@ importers:
specifier: ^2.0.0
version: 2.0.0(@types/node@20.11.20)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
packages/connectors/connector-patreon:
dependencies:
'@logto/connector-kit':
specifier: workspace:^4.0.0
version: link:../../toolkit/connector-kit
'@logto/connector-oauth':
specifier: workspace:^1.4.0
version: link:../connector-oauth2
'@silverhand/essentials':
specifier: ^2.9.1
version: 2.9.1
ky:
specifier: ^1.2.3
version: 1.2.3
zod:
specifier: ^3.23.8
version: 3.23.8
devDependencies:
'@silverhand/eslint-config':
specifier: 6.0.1
version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3)
'@silverhand/ts-config':
specifier: 6.0.0
version: 6.0.0(typescript@5.5.3)
'@types/node':
specifier: ^20.11.20
version: 20.12.7
'@types/supertest':
specifier: ^6.0.2
version: 6.0.2
'@vitest/coverage-v8':
specifier: ^2.0.0
version: 2.0.0(vitest@2.0.0(@types/node@20.12.7)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8))
eslint:
specifier: ^8.56.0
version: 8.57.0
lint-staged:
specifier: ^15.0.2
version: 15.0.2
nock:
specifier: 14.0.0-beta.9
version: 14.0.0-beta.9
prettier:
specifier: ^3.0.0
version: 3.0.0
supertest:
specifier: ^7.0.0
version: 7.0.0
tsup:
specifier: ^8.1.0
version: 8.1.0(@swc/core@1.3.52(@swc/helpers@0.5.1))(postcss@8.4.39)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3))(typescript@5.5.3)
typescript:
specifier: ^5.5.3
version: 5.5.3
vitest:
specifier: ^2.0.0
version: 2.0.0(@types/node@20.12.7)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
packages/connectors/connector-postmark:
dependencies:
'@logto/connector-kit':