diff --git a/packages/core/src/connectors/github/index.test.ts b/packages/core/src/connectors/github/index.test.ts index 02b82d646..c8a4f357e 100644 --- a/packages/core/src/connectors/github/index.test.ts +++ b/packages/core/src/connectors/github/index.test.ts @@ -33,12 +33,12 @@ describe('getAccessToken', () => { scope: 'scope', token_type: 'token_type', }); - const accessToken = await getAccessToken('code'); + const accessToken = await 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(getAccessToken('code')).rejects.toMatchError( + await expect(getAccessToken('code', 'dummyRedirectUri')).rejects.toMatchError( new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) ); }); diff --git a/packages/core/src/connectors/github/index.ts b/packages/core/src/connectors/github/index.ts index 369b88cc1..4f525329d 100644 --- a/packages/core/src/connectors/github/index.ts +++ b/packages/core/src/connectors/github/index.ts @@ -12,7 +12,7 @@ import { ConnectorError, ConnectorErrorCodes, } from '../types'; -import { getConnectorConfig } from '../utilities'; +import { getConnectorConfig, getConnectorRequestTimeout } from '../utilities'; import { authorizationEndpoint, accessTokenEndpoint, scope, userInfoEndpoint } from './constant'; export const metadata: ConnectorMetadata = { @@ -77,6 +77,7 @@ export const getAccessToken: GetAccessToken = async (code) => { client_secret, code, }, + timeout: await getConnectorRequestTimeout(), }) .json(); @@ -90,9 +91,9 @@ export const getAccessToken: GetAccessToken = async (code) => { export const getUserInfo: GetUserInfo = async (accessToken: string) => { type UserInfoResponse = { id: number; - avatar_url: string; - email: string; - name: string; + avatar_url?: string; + email?: string; + name?: string; }; try { @@ -106,6 +107,7 @@ export const getUserInfo: GetUserInfo = async (accessToken: string) => { headers: { authorization: `token ${accessToken}`, }, + timeout: await getConnectorRequestTimeout(), }) .json(); diff --git a/packages/core/src/connectors/google/constant.ts b/packages/core/src/connectors/google/constant.ts new file mode 100644 index 000000000..513dcf0c8 --- /dev/null +++ b/packages/core/src/connectors/google/constant.ts @@ -0,0 +1,4 @@ +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'; diff --git a/packages/core/src/connectors/google/index.test.ts b/packages/core/src/connectors/google/index.test.ts new file mode 100644 index 000000000..5c04942de --- /dev/null +++ b/packages/core/src/connectors/google/index.test.ts @@ -0,0 +1,96 @@ +import nock from 'nock'; + +import { validateConfig, getAuthorizationUri, getAccessToken, getUserInfo } from '.'; +import { ConnectorError, ConnectorErrorCodes } from '../types'; +import { getConnectorConfig } from '../utilities'; +import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant'; + +jest.mock('../utilities'); + +beforeAll(() => { + (getConnectorConfig as jest.MockedFunction).mockResolvedValue({ + clientId: '', + clientSecret: '', + }); +}); + +describe('google connector', () => { + describe('validateConfig', () => { + it('should pass on valid config', async () => { + await expect( + validateConfig({ clientId: 'clientId', clientSecret: 'clientSecret' }) + ).resolves.not.toThrow(); + }); + + it('should throw on invalid config', async () => { + await expect(validateConfig({})).rejects.toThrow(); + await expect(validateConfig({ clientId: 'clientId' })).rejects.toThrow(); + await expect(validateConfig({ clientSecret: 'clientSecret' })).rejects.toThrow(); + }); + }); + + describe('getAuthorizationUri', () => { + it('should get a valid authorizationUri with redirectUri and state', async () => { + const authorizationUri = await 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&scope=openid%20profile%20email&state=some_state` + ); + }); + }); + + describe('getAccessToken', () => { + 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 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(getAccessToken('code', 'dummyRedirectUri')).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) + ); + }); + }); + + describe('getUserInfo', () => { + it('shoud 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 getUserInfo('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(getUserInfo('code')).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) + ); + }); + + it('throws unrecognized error', async () => { + nock(userInfoEndpoint).post('').reply(500); + await expect(getUserInfo('code')).rejects.toThrow(); + }); + }); +}); diff --git a/packages/core/src/connectors/google/index.ts b/packages/core/src/connectors/google/index.ts new file mode 100644 index 000000000..a460bf074 --- /dev/null +++ b/packages/core/src/connectors/google/index.ts @@ -0,0 +1,137 @@ +/** + * The Implementation of OpenID Connect of Google Identity Platform. + * https://developers.google.com/identity/protocols/oauth2/openid-connect + */ + +import { conditional } from '@silverhand/essentials'; +import got, { RequestError as GotRequestError } from 'got'; +import { stringify } from 'query-string'; +import { z } from 'zod'; + +import { + ConnectorError, + ConnectorErrorCodes, + ConnectorMetadata, + ConnectorType, + GetAccessToken, + GetAuthorizationUri, + GetUserInfo, + ValidateConfig, +} from '../types'; +import { getConnectorConfig, getConnectorRequestTimeout } from '../utilities'; +import { accessTokenEndpoint, authorizationEndpoint, scope, userInfoEndpoint } from './constant'; + +export const metadata: 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登录', + }, +}; + +const googleConfigGuard = z.object({ + clientId: z.string(), + clientSecret: z.string(), +}); + +type GoogleConfig = z.infer; + +export const validateConfig: ValidateConfig = async (config: unknown) => { + const result = googleConfigGuard.safeParse(config); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); + } +}; + +export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { + const config = await getConnectorConfig(metadata.id); + + return `${authorizationEndpoint}?${stringify({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: 'code', + state, + scope, + })}`; +}; + +export const getAccessToken: GetAccessToken = async (code, redirectUri) => { + type AccessTokenResponse = { + access_token: string; + scope: string; + token_type: string; + }; + + const { clientId, clientSecret } = await getConnectorConfig(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: await getConnectorRequestTimeout(), + followRedirect: true, + }) + .json(); + + if (!accessToken) { + throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid); + } + + return accessToken; +}; + +export const getUserInfo: GetUserInfo = async (accessToken: string) => { + type UserInfoResponse = { + sub: string; + name?: string; + given_name?: string; + family_name?: string; + picture?: string; + email?: string; + email_verified?: boolean; + locale?: string; + }; + + try { + const { + sub: id, + picture: avatar, + email, + email_verified, + name, + } = await got + .post(userInfoEndpoint, { + headers: { + authorization: `Bearer ${accessToken}`, + }, + timeout: await getConnectorRequestTimeout(), + }) + .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/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts index 282fcb406..1367f625e 100644 --- a/packages/core/src/connectors/index.ts +++ b/packages/core/src/connectors/index.ts @@ -4,9 +4,10 @@ import { findConnectorById, hasConnector, insertConnector } from '@/queries/conn import * as AliyunDM from './aliyun-dm'; import * as AliyunSMS from './aliyun-sms'; import * as GitHub from './github'; +import * as Google from './google'; import { ConnectorInstance, ConnectorType, IConnector, SocialConnectorInstance } from './types'; -const allConnectors: IConnector[] = [AliyunDM, AliyunSMS, GitHub]; +const allConnectors: IConnector[] = [AliyunDM, AliyunSMS, GitHub, Google]; export const getConnectorInstances = async (): Promise => { return Promise.all( diff --git a/packages/core/src/connectors/types.ts b/packages/core/src/connectors/types.ts index c74a74b63..2e2700140 100644 --- a/packages/core/src/connectors/types.ts +++ b/packages/core/src/connectors/types.ts @@ -98,7 +98,7 @@ export type ValidateConfig = ( export type GetAuthorizationUri = (redirectUri: string, state: string) => Promise; -export type GetAccessToken = (code: string) => Promise; +export type GetAccessToken = (code: string, redirectUri: string) => Promise; export type GetUserInfo = (accessToken: string) => Promise; diff --git a/packages/core/src/connectors/utilities/index.ts b/packages/core/src/connectors/utilities/index.ts index 86dafc805..4b7efc7e2 100644 --- a/packages/core/src/connectors/utilities/index.ts +++ b/packages/core/src/connectors/utilities/index.ts @@ -17,3 +17,7 @@ export const updateConnectorConfig = async ( set: { config }, }); }; + +const connectorRequestTimeout = 5000; + +export const getConnectorRequestTimeout = async (): Promise => connectorRequestTimeout; diff --git a/packages/core/src/lib/social.ts b/packages/core/src/lib/social.ts index 724cf35e2..2b2ee4731 100644 --- a/packages/core/src/lib/social.ts +++ b/packages/core/src/lib/social.ts @@ -36,10 +36,11 @@ const getConnector = async (connectorId: string) => { export const getUserInfoByAuthCode = async ( connectorId: string, - authCode: string + authCode: string, + redirectUri: string ): Promise => { const connector = await getConnector(connectorId); - const accessToken = await connector.getAccessToken(authCode); + const accessToken = await connector.getAccessToken(authCode, redirectUri); return connector.getUserInfo(accessToken); }; diff --git a/packages/core/src/routes/session.ts b/packages/core/src/routes/session.ts index 787e86b9e..bead1ed94 100644 --- a/packages/core/src/routes/session.ts +++ b/packages/core/src/routes/session.ts @@ -191,7 +191,7 @@ export default function sessionRoutes(router: T, prov return next(); } - const userInfo = await getUserInfoByAuthCode(connectorId, code); + const userInfo = await getUserInfoByAuthCode(connectorId, code, redirectUri); if (!(await hasUserWithIdentity(connectorId, userInfo.id))) { await assignInteractionResults(ctx, provider, { connectorId, userInfo }, true);