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

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()
This commit is contained in:
Darcy Ye 2022-04-28 15:16:05 +08:00 committed by GitHub
parent 3a956fae10
commit 2049b4f73a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 448 additions and 0 deletions

View file

@ -0,0 +1,2 @@
### Google Social Connector README
placeholder

View file

@ -0,0 +1,6 @@
```json
{
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}
```

View file

@ -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;

View file

@ -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. <contact@silverhand.io>",
"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"
}

View file

@ -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<typeof googleConfigGuard>;
// 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;

View file

@ -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<GoogleConfig>;
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();
});
});
});

View file

@ -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<GoogleConfig>;
constructor(getConnectorConfig: GetConnectorConfig<GoogleConfig>) {
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);
// NoteNeed 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<AccessTokenResponse>();
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<UserInfoResponse>();
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;
}
};
}

View file

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

View file

@ -0,0 +1,10 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.base",
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base",
"compilerOptions": {
"types": ["node", "jest", "jest-matcher-specific-error"]
},
"include": ["src", "jest.config.ts"]
}

View file

@ -0,0 +1,6 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"isolatedModules": false
}
}

View file

@ -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