Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Patreon connector #6514

Merged
merged 9 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/selfish-kangaroos-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/connector-patreon": major
---

Adding a new patreon social connector for Logto
devtekve marked this conversation as resolved.
Show resolved Hide resolved
22 changes: 22 additions & 0 deletions packages/connectors/connector-patreon/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# @logto/connector-patreon

## 0.0.1

### 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.

- Base code adapted from the Hugging Face connector.

The Patreon connector's initial version is largely based on the Hugging Face connector developed by Silverhand Inc. and the Logto team. Many implementation patterns, error handling strategies, and configuration options were inspired by the Hugging Face connector, leveraging OAuth 2.0 which saved time and complexity. Acknowledgments to @xiaoyijun for pointing it out and to the original creators at Silverhand Inc. for their foundational work.
devtekve marked this conversation as resolved.
Show resolved Hide resolved

### 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 changes: 60 additions & 0 deletions packages/connectors/connector-patreon/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions packages/connectors/connector-patreon/logo-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions packages/connectors/connector-patreon/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 69 additions & 0 deletions packages/connectors/connector-patreon/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"name": "@logto/connector-patreon",
"version": "0.0.1",
"description": "Patreon web connector implementation.",
"author": "DevTekVE <[email protected]> & Silverhand Inc. <[email protected]>",
"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"
}
}
52 changes: 52 additions & 0 deletions packages/connectors/connector-patreon/src/constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';

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: [
{
key: 'clientId',
type: ConnectorConfigFormItemType.Text,
label: 'Client ID',
required: true,
placeholder: '<client-id>',
},
{
key: 'clientSecret',
type: ConnectorConfigFormItemType.Text,
label: 'Client Secret',
required: true,
placeholder: '<client-secret>',
},
{
key: 'scope',
type: ConnectorConfigFormItemType.Text,
label: 'Scope',
required: false,
placeholder: '<scope>',
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.",
},
],
devtekve marked this conversation as resolved.
Show resolved Hide resolved
};

export const defaultTimeout = 5000;
155 changes: 155 additions & 0 deletions packages/connectors/connector-patreon/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
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: '[email protected]',
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: '[email protected]',
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)
);
});
});
Loading
Loading