From 2049b4f73a9680699fd6ba9b85ce29a1ebc49719 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 28 Apr 2022 15:16:05 +0800 Subject: [PATCH] feat(core): wrap google connector (#674) * feat(core): wrap google connector * feat(core): connectors package.json private should be FALSE * feat(core): fix dependency and remove query-string.stringify() --- packages/connector-google/README.md | 2 + .../connector-google/docs/config-template.md | 6 + packages/connector-google/jest.config.ts | 8 ++ packages/connector-google/package.json | 57 ++++++++ packages/connector-google/src/constant.ts | 42 ++++++ packages/connector-google/src/index.test.ts | 120 ++++++++++++++++ packages/connector-google/src/index.ts | 134 ++++++++++++++++++ packages/connector-google/src/mock.ts | 4 + packages/connector-google/tsconfig.base.json | 10 ++ packages/connector-google/tsconfig.build.json | 5 + packages/connector-google/tsconfig.json | 7 + packages/connector-google/tsconfig.test.json | 6 + pnpm-lock.yaml | 47 ++++++ 13 files changed, 448 insertions(+) create mode 100644 packages/connector-google/README.md create mode 100644 packages/connector-google/docs/config-template.md create mode 100644 packages/connector-google/jest.config.ts create mode 100644 packages/connector-google/package.json create mode 100644 packages/connector-google/src/constant.ts create mode 100644 packages/connector-google/src/index.test.ts create mode 100644 packages/connector-google/src/index.ts create mode 100644 packages/connector-google/src/mock.ts create mode 100644 packages/connector-google/tsconfig.base.json create mode 100644 packages/connector-google/tsconfig.build.json create mode 100644 packages/connector-google/tsconfig.json create mode 100644 packages/connector-google/tsconfig.test.json diff --git a/packages/connector-google/README.md b/packages/connector-google/README.md new file mode 100644 index 000000000..dcb92c2c7 --- /dev/null +++ b/packages/connector-google/README.md @@ -0,0 +1,2 @@ +### Google Social Connector README +placeholder diff --git a/packages/connector-google/docs/config-template.md b/packages/connector-google/docs/config-template.md new file mode 100644 index 000000000..6e9deec3a --- /dev/null +++ b/packages/connector-google/docs/config-template.md @@ -0,0 +1,6 @@ +```json +{ + "clientId": "", + "clientSecret": "" +} +``` diff --git a/packages/connector-google/jest.config.ts b/packages/connector-google/jest.config.ts new file mode 100644 index 000000000..d8ceb361c --- /dev/null +++ b/packages/connector-google/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-google/package.json b/packages/connector-google/package.json new file mode 100644 index 000000000..a3cd393e8 --- /dev/null +++ b/packages/connector-google/package.json @@ -0,0 +1,57 @@ +{ + "name": "@logto/connector-google", + "version": "0.1.0", + "description": "Google 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", + "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-google/src/constant.ts b/packages/connector-google/src/constant.ts new file mode 100644 index 000000000..fa16e857e --- /dev/null +++ b/packages/connector-google/src/constant.ts @@ -0,0 +1,42 @@ +import path from 'path'; + +import { ConnectorMetadata, ConnectorType } from '@logto/connector-types'; +import { getFileContents } from '@logto/shared'; +import { z } from 'zod'; + +export const authorizationEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth'; +export const accessTokenEndpoint = 'https://oauth2.googleapis.com/token'; +export const userInfoEndpoint = 'https://openidconnect.googleapis.com/v1/userinfo'; +export const scope = 'openid profile email'; + +export const googleConfigGuard = z.object({ + clientId: z.string(), + clientSecret: z.string(), +}); + +export type GoogleConfig = 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: 'google', + type: ConnectorType.Social, + name: { + en: 'Sign In with Google', + 'zh-CN': 'Google登录', + }, + logo: './logo.png', + description: { + en: 'Sign In with Google', + 'zh-CN': 'Google登录', + }, + readme: getFileContents(pathToReadmeFile, readmeContentFallback), + configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback), +}; + +export const defaultTimeout = 5000; diff --git a/packages/connector-google/src/index.test.ts b/packages/connector-google/src/index.test.ts new file mode 100644 index 000000000..adaebd41a --- /dev/null +++ b/packages/connector-google/src/index.test.ts @@ -0,0 +1,120 @@ +import { ConnectorError, ConnectorErrorCodes, GetConnectorConfig } from '@logto/connector-types'; +import nock from 'nock'; + +import { GoogleConnector } from '.'; +import { + GoogleConfig, + accessTokenEndpoint, + authorizationEndpoint, + userInfoEndpoint, +} from './constant'; +import { mockedConfig } from './mock'; + +const getConnectorConfig = jest.fn() as GetConnectorConfig; + +const googleMethods = new GoogleConnector(getConnectorConfig); + +beforeAll(() => { + jest.spyOn(googleMethods, 'getConfig').mockResolvedValue(mockedConfig); +}); + +describe('google connector', () => { + describe('validateConfig', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass on valid config', async () => { + await expect( + googleMethods.validateConfig({ clientId: 'clientId', clientSecret: 'clientSecret' }) + ).resolves.not.toThrow(); + }); + + it('should throw on invalid config', async () => { + await expect(googleMethods.validateConfig({})).rejects.toThrow(); + await expect(googleMethods.validateConfig({ clientId: 'clientId' })).rejects.toThrow(); + await expect( + googleMethods.validateConfig({ clientSecret: 'clientSecret' }) + ).rejects.toThrow(); + }); + }); + + describe('getAuthorizationUri', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should get a valid authorizationUri with redirectUri and state', async () => { + const authorizationUri = await googleMethods.getAuthorizationUri( + 'http://localhost:3000/callback', + 'some_state' + ); + expect(authorizationUri).toEqual( + `${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&response_type=code&state=some_state&scope=openid+profile+email` + ); + }); + }); + + describe('getAccessToken', () => { + afterEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + }); + + it('should get an accessToken by exchanging with code', async () => { + nock(accessTokenEndpoint).post('').reply(200, { + access_token: 'access_token', + scope: 'scope', + token_type: 'token_type', + }); + const { accessToken } = await googleMethods.getAccessToken('code', 'dummyRedirectUri'); + expect(accessToken).toEqual('access_token'); + }); + + it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => { + nock(accessTokenEndpoint).post('').reply(200, {}); + await expect(googleMethods.getAccessToken('code', 'dummyRedirectUri')).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) + ); + }); + }); + + describe('getUserInfo', () => { + afterEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + }); + + it('should get valid SocialUserInfo', async () => { + nock(userInfoEndpoint).post('').reply(200, { + sub: '1234567890', + name: 'monalisa octocat', + given_name: 'monalisa', + family_name: 'octocat', + picture: 'https://github.com/images/error/octocat_happy.gif', + email: 'octocat@google.com', + email_verified: true, + locale: 'en', + }); + const socialUserInfo = await googleMethods.getUserInfo({ accessToken: 'code' }); + expect(socialUserInfo).toMatchObject({ + id: '1234567890', + avatar: 'https://github.com/images/error/octocat_happy.gif', + name: 'monalisa octocat', + email: 'octocat@google.com', + }); + }); + + it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => { + nock(userInfoEndpoint).post('').reply(401); + await expect(googleMethods.getUserInfo({ accessToken: 'code' })).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) + ); + }); + + it('throws unrecognized error', async () => { + nock(userInfoEndpoint).post('').reply(500); + await expect(googleMethods.getUserInfo({ accessToken: 'code' })).rejects.toThrow(); + }); + }); +}); diff --git a/packages/connector-google/src/index.ts b/packages/connector-google/src/index.ts new file mode 100644 index 000000000..534d660b9 --- /dev/null +++ b/packages/connector-google/src/index.ts @@ -0,0 +1,134 @@ +/** + * The Implementation of OpenID Connect of Google Identity Platform. + * https://developers.google.com/identity/protocols/oauth2/openid-connect + */ +import { + ConnectorError, + ConnectorErrorCodes, + GetAccessToken, + GetAuthorizationUri, + GetUserInfo, + ConnectorMetadata, + ValidateConfig, + SocialConnector, + GetConnectorConfig, +} from '@logto/connector-types'; +import { conditional, assert } from '@silverhand/essentials'; +import got, { RequestError as GotRequestError } from 'got'; + +import { + accessTokenEndpoint, + authorizationEndpoint, + scope, + userInfoEndpoint, + googleConfigGuard, + GoogleConfig, + defaultMetadata, + defaultTimeout, +} from './constant'; + +export class GoogleConnector implements SocialConnector { + public metadata: ConnectorMetadata = defaultMetadata; + + public readonly getConfig: GetConnectorConfig; + + constructor(getConnectorConfig: GetConnectorConfig) { + this.getConfig = getConnectorConfig; + } + + public validateConfig: ValidateConfig = async (config: unknown) => { + const result = googleConfigGuard.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); + + const queryParameters = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: 'code', + state, + scope, + }); + + return `${authorizationEndpoint}?${queryParameters.toString()}`; + }; + + public getAccessToken: GetAccessToken = async (code, redirectUri) => { + type AccessTokenResponse = { + access_token: string; + scope: string; + token_type: string; + }; + + const { clientId, clientSecret } = await this.getConfig(this.metadata.id); + + // Note:Need to decodeURIComponent on code + // https://stackoverflow.com/questions/51058256/google-api-node-js-invalid-grant-malformed-auth-code + const { access_token: accessToken } = await got + .post(accessTokenEndpoint, { + form: { + code: decodeURIComponent(code), + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }, + timeout: defaultTimeout, + followRedirect: true, + }) + .json(); + + assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); + + return { accessToken }; + }; + + public getUserInfo: GetUserInfo = async (accessTokenObject) => { + type UserInfoResponse = { + sub: string; + name?: string; + given_name?: string; + family_name?: string; + picture?: string; + email?: string; + email_verified?: boolean; + locale?: string; + }; + + const { accessToken } = accessTokenObject; + + try { + const { + sub: id, + picture: avatar, + email, + email_verified, + name, + } = await got + .post(userInfoEndpoint, { + headers: { + authorization: `Bearer ${accessToken}`, + }, + timeout: defaultTimeout, + }) + .json(); + + return { + id, + avatar, + email: conditional(email_verified && email), + name, + }; + } catch (error: unknown) { + if (error instanceof GotRequestError && error.response?.statusCode === 401) { + throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); + } + throw error; + } + }; +} diff --git a/packages/connector-google/src/mock.ts b/packages/connector-google/src/mock.ts new file mode 100644 index 000000000..a52d77ccc --- /dev/null +++ b/packages/connector-google/src/mock.ts @@ -0,0 +1,4 @@ +export const mockedConfig = { + clientId: '', + clientSecret: '', +}; diff --git a/packages/connector-google/tsconfig.base.json b/packages/connector-google/tsconfig.base.json new file mode 100644 index 000000000..848a915f7 --- /dev/null +++ b/packages/connector-google/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/packages/connector-google/tsconfig.build.json b/packages/connector-google/tsconfig.build.json new file mode 100644 index 000000000..d42923dd3 --- /dev/null +++ b/packages/connector-google/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base", + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-google/tsconfig.json b/packages/connector-google/tsconfig.json new file mode 100644 index 000000000..20354364a --- /dev/null +++ b/packages/connector-google/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-google/tsconfig.test.json b/packages/connector-google/tsconfig.test.json new file mode 100644 index 000000000..98c16f367 --- /dev/null +++ b/packages/connector-google/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "isolatedModules": false + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de914aaaa..da3d9936d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,53 @@ importers: tsc-watch: 4.6.2_typescript@4.6.3 typescript: 4.6.3 + packages/connector-google: + 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 + 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 + 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