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:
parent
dcd84086f6
commit
8ae42e9666
10 changed files with 256 additions and 11 deletions
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>();
|
||||
|
||||
|
|
4
packages/core/src/connectors/google/constant.ts
Normal file
4
packages/core/src/connectors/google/constant.ts
Normal 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';
|
96
packages/core/src/connectors/google/index.test.ts
Normal file
96
packages/core/src/connectors/google/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
137
packages/core/src/connectors/google/index.ts
Normal file
137
packages/core/src/connectors/google/index.ts
Normal 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);
|
||||
|
||||
// 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<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;
|
||||
}
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -17,3 +17,7 @@ export const updateConnectorConfig = async <T extends ArbitraryObject>(
|
|||
set: { config },
|
||||
});
|
||||
};
|
||||
|
||||
const connectorRequestTimeout = 5000;
|
||||
|
||||
export const getConnectorRequestTimeout = async (): Promise<number> => connectorRequestTimeout;
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue