diff --git a/packages/connector-facebook/README.md b/packages/connector-facebook/README.md new file mode 100644 index 000000000..1cd436100 --- /dev/null +++ b/packages/connector-facebook/README.md @@ -0,0 +1,2 @@ +### FB Social Connector README +placeholder diff --git a/packages/connector-facebook/docs/config-template.md b/packages/connector-facebook/docs/config-template.md new file mode 100644 index 000000000..6e9deec3a --- /dev/null +++ b/packages/connector-facebook/docs/config-template.md @@ -0,0 +1,6 @@ +```json +{ + "clientId": "", + "clientSecret": "" +} +``` diff --git a/packages/connector-facebook/jest.config.ts b/packages/connector-facebook/jest.config.ts new file mode 100644 index 000000000..d8ceb361c --- /dev/null +++ b/packages/connector-facebook/jest.config.ts @@ -0,0 +1,8 @@ +import { Config, merge } from '@logto/jest-config'; + +const config: Config.InitialOptions = merge({ + testEnvironment: 'node', + setupFilesAfterEnv: ['jest-matcher-specific-error'], +}); + +export default config; diff --git a/packages/connector-facebook/package.json b/packages/connector-facebook/package.json new file mode 100644 index 000000000..2a2e3a328 --- /dev/null +++ b/packages/connector-facebook/package.json @@ -0,0 +1,58 @@ +{ + "name": "@logto/connector-facebook", + "version": "0.1.0", + "description": "Facebook web connector implementation.", + "main": "./lib/index.js", + "exports": "./lib/index.js", + "author": "Silverhand Inc. ", + "license": "MPL-2.0", + "files": [ + "lib", + "docs" + ], + "private": false, + "scripts": { + "preinstall": "npx only-allow pnpm", + "precommit": "lint-staged", + "build": "rm -rf lib/ && tsc -p tsconfig.build.json", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint -- --format json --output-file report.json", + "dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"", + "test": "jest", + "test:coverage": "jest --coverage --silent", + "prepack": "pnpm build" + }, + "dependencies": { + "@logto/connector-types": "^0.1.0", + "@logto/jest-config": "^0.1.0", + "@logto/shared": "^0.1.0", + "@logto/schemas": "^0.1.0", + "@silverhand/essentials": "^1.1.0", + "got": "^11.8.2", + "query-string": "^7.0.1", + "zod": "^3.14.3" + }, + "devDependencies": { + "@jest/types": "^27.5.1", + "@silverhand/eslint-config": "^0.10.2", + "@silverhand/ts-config": "^0.10.2", + "@types/jest": "^27.4.1", + "@types/node": "^16.3.1", + "eslint": "^8.10.0", + "jest": "^27.5.1", + "jest-matcher-specific-error": "^1.0.0", + "lint-staged": "^11.1.1", + "nock": "^13.2.2", + "prettier": "^2.3.2", + "ts-jest": "^27.1.1", + "tsc-watch": "^4.4.0", + "typescript": "^4.6.2" + }, + "engines": { + "node": "^16.0.0" + }, + "eslintConfig": { + "extends": "@silverhand" + }, + "prettier": "@silverhand/eslint-config/.prettierrc" +} diff --git a/packages/connector-facebook/src/constant.ts b/packages/connector-facebook/src/constant.ts new file mode 100644 index 000000000..5e568c976 --- /dev/null +++ b/packages/connector-facebook/src/constant.ts @@ -0,0 +1,51 @@ +import path from 'path'; + +import { ConnectorMetadata, ConnectorType } from '@logto/connector-types'; +import { getFileContents } from '@logto/shared'; +import { z } from 'zod'; + +/** + * Note: If you do not include a version number we will default to the oldest available version, so it's recommended to include the version number in your requests. + * https://developers.facebook.com/docs/graph-api/overview#versions + */ +export const authorizationEndpoint = 'https://www.facebook.com/v13.0/dialog/oauth'; +export const accessTokenEndpoint = 'https://graph.facebook.com/v13.0/oauth/access_token'; +/** + * Note: The /me node is a special endpoint that translates to the object ID of the person or Page whose access token is currently being used to make the API calls. + * https://developers.facebook.com/docs/graph-api/overview#me + * https://developers.facebook.com/docs/graph-api/reference/user#Reading + */ +export const userInfoEndpoint = 'https://graph.facebook.com/v13.0/me'; +export const scope = 'email,public_profile'; + +export const facebookConfigGuard = z.object({ + clientId: z.string(), + clientSecret: z.string(), +}); + +export type FacebookConfig = z.infer; + +// eslint-disable-next-line unicorn/prefer-module +const currentPath = __dirname; +const pathToReadmeFile = path.join(currentPath, '..', 'README.md'); +const pathToConfigTemplate = path.join(currentPath, '..', 'docs', 'config-template.md'); +const readmeContentFallback = 'Please check README.md file directory.'; +const configTemplateFallback = 'Please check config-template.md file directory.'; + +export const defaultMetadata: ConnectorMetadata = { + id: 'facebook', + type: ConnectorType.Social, + name: { + en: 'Sign In with Facebook', + 'zh-CN': 'Facebook 登录', + }, + logo: './logo.png', + description: { + en: 'Sign In with Facebook', + 'zh-CN': 'Facebook 登录', + }, + readme: getFileContents(pathToReadmeFile, readmeContentFallback), + configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback), +}; + +export const defaultTimeout = 5000; diff --git a/packages/connector-facebook/src/index.test.ts b/packages/connector-facebook/src/index.test.ts new file mode 100644 index 000000000..0be73346a --- /dev/null +++ b/packages/connector-facebook/src/index.test.ts @@ -0,0 +1,138 @@ +import { ConnectorError, ConnectorErrorCodes, GetConnectorConfig } from '@logto/connector-types'; +import nock from 'nock'; + +import { FacebookConnector } from '.'; +import { + FacebookConfig, + accessTokenEndpoint, + authorizationEndpoint, + userInfoEndpoint, +} from './constant'; +import { clientId, clientSecret, code, dummyRedirectUri, fields, mockedConfig } from './mock'; + +const getConnectorConfig = jest.fn() as GetConnectorConfig; + +const facebookMethods = new FacebookConnector(getConnectorConfig); + +beforeAll(() => { + jest.spyOn(facebookMethods, 'getConfig').mockResolvedValue(mockedConfig); +}); + +describe('facebook connector', () => { + describe('validateConfig', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass on valid config', async () => { + await expect( + facebookMethods.validateConfig({ clientId, clientSecret }) + ).resolves.not.toThrow(); + }); + + it('should throw on invalid config', async () => { + await expect(facebookMethods.validateConfig({})).rejects.toThrow(); + await expect(facebookMethods.validateConfig({ clientId })).rejects.toThrow(); + await expect(facebookMethods.validateConfig({ clientSecret })).rejects.toThrow(); + }); + }); + + describe('getAuthorizationUri', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should get a valid authorizationUri with redirectUri and state', async () => { + const redirectUri = 'http://localhost:3000/callback'; + const state = 'some_state'; + const authorizationUri = await facebookMethods.getAuthorizationUri(redirectUri, state); + + const encodedRedirectUri = encodeURIComponent(redirectUri); + expect(authorizationUri).toEqual( + `${authorizationEndpoint}?client_id=${clientId}&redirect_uri=${encodedRedirectUri}&response_type=code&scope=email%2Cpublic_profile&state=${state}` + ); + }); + }); + + describe('getAccessToken', () => { + afterEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + }); + + it('should get an accessToken by exchanging with code', async () => { + nock(accessTokenEndpoint) + .get('') + .query({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: dummyRedirectUri, + }) + .reply(200, { + access_token: 'access_token', + scope: 'scope', + token_type: 'token_type', + }); + + const { accessToken } = await facebookMethods.getAccessToken(code, dummyRedirectUri); + expect(accessToken).toEqual('access_token'); + }); + + it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => { + nock(accessTokenEndpoint) + .get('') + .query({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: dummyRedirectUri, + }) + .reply(200, {}); + + await expect(facebookMethods.getAccessToken(code, dummyRedirectUri)).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) + ); + }); + }); + + describe('getUserInfo', () => { + afterEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + }); + + it('should get valid SocialUserInfo', async () => { + const avatar = 'https://github.com/images/error/octocat_happy.gif'; + nock(userInfoEndpoint) + .get('') + .query({ fields }) + .reply(200, { + id: '1234567890', + name: 'monalisa octocat', + email: 'octocat@facebook.com', + picture: { data: { url: avatar } }, + }); + + const socialUserInfo = await facebookMethods.getUserInfo({ accessToken: code }); + expect(socialUserInfo).toMatchObject({ + id: '1234567890', + avatar, + name: 'monalisa octocat', + email: 'octocat@facebook.com', + }); + }); + + it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => { + nock(userInfoEndpoint).get('').query({ fields }).reply(400); + await expect(facebookMethods.getUserInfo({ accessToken: code })).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) + ); + }); + + it('throws unrecognized error', async () => { + nock(userInfoEndpoint).get('').reply(500); + await expect(facebookMethods.getUserInfo({ accessToken: code })).rejects.toThrow(); + }); + }); +}); diff --git a/packages/connector-facebook/src/index.ts b/packages/connector-facebook/src/index.ts new file mode 100644 index 000000000..f1e7d11cb --- /dev/null +++ b/packages/connector-facebook/src/index.ts @@ -0,0 +1,125 @@ +/** + * Reference: Manually Build a Login Flow + * https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow + */ + +import { + ConnectorError, + ConnectorErrorCodes, + ConnectorMetadata, + GetAccessToken, + GetAuthorizationUri, + GetUserInfo, + ValidateConfig, + SocialConnector, + GetConnectorConfig, +} from '@logto/connector-types'; +import { assert } from '@silverhand/essentials'; +import got, { RequestError as GotRequestError } from 'got'; +import { stringify } from 'query-string'; + +import { + accessTokenEndpoint, + authorizationEndpoint, + scope, + userInfoEndpoint, + defaultMetadata, + defaultTimeout, + facebookConfigGuard, + FacebookConfig, +} from './constant'; + +export class FacebookConnector implements SocialConnector { + public metadata: ConnectorMetadata = defaultMetadata; + + public readonly getConfig: GetConnectorConfig; + + constructor(getConnectorConfig: GetConnectorConfig) { + this.getConfig = getConnectorConfig; + } + + public validateConfig: ValidateConfig = async (config: unknown) => { + const result = facebookConfigGuard.safeParse(config); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); + } + }; + + public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { + const config = await this.getConfig(this.metadata.id); + + return `${authorizationEndpoint}?${stringify({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: 'code', + state, + scope, // Only support fixed scope for v1. + })}`; + }; + + public getAccessToken: GetAccessToken = async (code, redirectUri) => { + type AccessTokenResponse = { + access_token: string; + token_type: string; + expires_in: number; + }; + + const { clientId: client_id, clientSecret: client_secret } = await this.getConfig( + this.metadata.id + ); + + const { access_token: accessToken } = await got + .get(accessTokenEndpoint, { + searchParams: { + code, + client_id, + client_secret, + redirect_uri: redirectUri, + }, + timeout: defaultTimeout, + }) + .json(); + + assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); + + return { accessToken }; + }; + + public getUserInfo: GetUserInfo = async (accessTokenObject) => { + type UserInfoResponse = { + id: string; + email?: string; + name?: string; + picture?: { data: { url: string } }; + }; + + const { accessToken } = accessTokenObject; + + try { + const { id, email, name, picture } = await got + .get(userInfoEndpoint, { + headers: { + authorization: `Bearer ${accessToken}`, + }, + searchParams: { + fields: 'id,name,email,picture', + }, + timeout: defaultTimeout, + }) + .json(); + + return { + id, + avatar: picture?.data.url, + email, + name, + }; + } catch (error: unknown) { + if (error instanceof GotRequestError && error.response?.statusCode === 400) { + throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); + } + throw error; + } + }; +} diff --git a/packages/connector-facebook/src/mock.ts b/packages/connector-facebook/src/mock.ts new file mode 100644 index 000000000..5d301dbea --- /dev/null +++ b/packages/connector-facebook/src/mock.ts @@ -0,0 +1,7 @@ +export const clientId = 'client_id_value'; +export const clientSecret = 'client_secret_value'; +export const code = 'code'; +export const dummyRedirectUri = 'dummyRedirectUri'; +export const fields = 'id,name,email,picture'; + +export const mockedConfig = { clientId, clientSecret }; diff --git a/packages/connector-facebook/tsconfig.base.json b/packages/connector-facebook/tsconfig.base.json new file mode 100644 index 000000000..848a915f7 --- /dev/null +++ b/packages/connector-facebook/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/packages/connector-facebook/tsconfig.build.json b/packages/connector-facebook/tsconfig.build.json new file mode 100644 index 000000000..d42923dd3 --- /dev/null +++ b/packages/connector-facebook/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base", + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-facebook/tsconfig.json b/packages/connector-facebook/tsconfig.json new file mode 100644 index 000000000..20354364a --- /dev/null +++ b/packages/connector-facebook/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base", + "compilerOptions": { + "types": ["node", "jest", "jest-matcher-specific-error"] + }, + "include": ["src", "jest.config.ts"] +} diff --git a/packages/connector-facebook/tsconfig.test.json b/packages/connector-facebook/tsconfig.test.json new file mode 100644 index 000000000..98c16f367 --- /dev/null +++ b/packages/connector-facebook/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "isolatedModules": false + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fc979409..beb455601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,55 @@ importers: tsc-watch: 4.6.2_typescript@4.6.3 typescript: 4.6.3 + packages/connector-facebook: + specifiers: + '@jest/types': ^27.5.1 + '@logto/connector-types': ^0.1.0 + '@logto/jest-config': ^0.1.0 + '@logto/schemas': ^0.1.0 + '@logto/shared': ^0.1.0 + '@silverhand/eslint-config': ^0.10.2 + '@silverhand/essentials': ^1.1.0 + '@silverhand/ts-config': ^0.10.2 + '@types/jest': ^27.4.1 + '@types/node': ^16.3.1 + eslint: ^8.10.0 + got: ^11.8.2 + jest: ^27.5.1 + jest-matcher-specific-error: ^1.0.0 + lint-staged: ^11.1.1 + nock: ^13.2.2 + prettier: ^2.3.2 + query-string: ^7.0.1 + ts-jest: ^27.1.1 + tsc-watch: ^4.4.0 + typescript: ^4.6.2 + zod: ^3.14.3 + dependencies: + '@logto/connector-types': link:../connector-types + '@logto/jest-config': link:../jest-config + '@logto/schemas': link:../schemas + '@logto/shared': link:../shared + '@silverhand/essentials': 1.1.7 + got: 11.8.3 + query-string: 7.0.1 + zod: 3.14.3 + devDependencies: + '@jest/types': 27.5.1 + '@silverhand/eslint-config': 0.10.2_bbe1a6794670f389df81805f22999709 + '@silverhand/ts-config': 0.10.2_typescript@4.6.3 + '@types/jest': 27.4.1 + '@types/node': 16.11.12 + eslint: 8.10.0 + jest: 27.5.1 + jest-matcher-specific-error: 1.0.0 + lint-staged: 11.2.6 + nock: 13.2.2 + prettier: 2.5.1 + ts-jest: 27.1.1_9985e1834e803358b7be1e6ce5ca0eea + tsc-watch: 4.6.2_typescript@4.6.3 + typescript: 4.6.3 + packages/connector-types: specifiers: '@jest/types': ^27.5.1