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

feat(core): google connector (#300)

This commit is contained in:
Xiao Yijun 2022-03-02 16:26:07 +08:00 committed by GitHub
parent dcd84086f6
commit 8ae42e9666
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 256 additions and 11 deletions

View file

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

View file

@ -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<AccessTokenResponse>();
@ -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<UserInfoResponse>();

View file

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

View file

@ -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<typeof getConnectorConfig>).mockResolvedValue({
clientId: '<client-id>',
clientSecret: '<client-secret>',
});
});
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();
});
});
});

View file

@ -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<typeof googleConfigGuard>;
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<GoogleConfig>(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<GoogleConfig>(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: await getConnectorRequestTimeout(),
followRedirect: true,
})
.json<AccessTokenResponse>();
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<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

@ -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<ConnectorInstance[]> => {
return Promise.all(

View file

@ -98,7 +98,7 @@ export type ValidateConfig<T extends ArbitraryObject = ArbitraryObject> = (
export type GetAuthorizationUri = (redirectUri: string, state: string) => Promise<string>;
export type GetAccessToken = (code: string) => Promise<string>;
export type GetAccessToken = (code: string, redirectUri: string) => Promise<string>;
export type GetUserInfo = (accessToken: string) => Promise<SocialUserInfo>;

View file

@ -17,3 +17,7 @@ export const updateConnectorConfig = async <T extends ArbitraryObject>(
set: { config },
});
};
const connectorRequestTimeout = 5000;
export const getConnectorRequestTimeout = async (): Promise<number> => connectorRequestTimeout;

View file

@ -36,10 +36,11 @@ const getConnector = async (connectorId: string) => {
export const getUserInfoByAuthCode = async (
connectorId: string,
authCode: string
authCode: string,
redirectUri: string
): Promise<SocialUserInfo> => {
const connector = await getConnector(connectorId);
const accessToken = await connector.getAccessToken(authCode);
const accessToken = await connector.getAccessToken(authCode, redirectUri);
return connector.getUserInfo(accessToken);
};

View file

@ -191,7 +191,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(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);