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:
parent
8beb758771
commit
aba089285b
11 changed files with 644 additions and 0 deletions
18
.changeset/selfish-kangaroos-perform.md
Normal file
18
.changeset/selfish-kangaroos-perform.md
Normal 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.
|
60
packages/connectors/connector-patreon/README.md
Normal file
60
packages/connectors/connector-patreon/README.md
Normal 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)
|
||||
|
||||
---
|
11
packages/connectors/connector-patreon/logo-dark.svg
Normal file
11
packages/connectors/connector-patreon/logo-dark.svg
Normal 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 |
8
packages/connectors/connector-patreon/logo.svg
Normal file
8
packages/connectors/connector-patreon/logo.svg
Normal 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 |
69
packages/connectors/connector-patreon/package.json
Normal file
69
packages/connectors/connector-patreon/package.json
Normal 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"
|
||||
}
|
||||
}
|
37
packages/connectors/connector-patreon/src/constant.ts
Normal file
37
packages/connectors/connector-patreon/src/constant.ts
Normal 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;
|
155
packages/connectors/connector-patreon/src/index.test.ts
Normal file
155
packages/connectors/connector-patreon/src/index.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
178
packages/connectors/connector-patreon/src/index.ts
Normal file
178
packages/connectors/connector-patreon/src/index.ts
Normal 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;
|
4
packages/connectors/connector-patreon/src/mock.ts
Normal file
4
packages/connectors/connector-patreon/src/mock.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export const mockedConfig = {
|
||||
clientId: '<client-id>',
|
||||
clientSecret: '<client-secret>',
|
||||
};
|
46
packages/connectors/connector-patreon/src/types.ts
Normal file
46
packages/connectors/connector-patreon/src/types.ts
Normal 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() });
|
|
@ -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':
|
||||
|
|
Loading…
Reference in a new issue