0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor: social connectors (#975)

* refactor: social connectors

* refactor(connectors): remove unused fixme comment

* fix(connectors): test
This commit is contained in:
Gao Sun 2022-05-29 13:59:00 +08:00 committed by GitHub
parent ddf113f1db
commit a686b1707a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 473 additions and 257 deletions

View file

@ -39,10 +39,10 @@ describe('getAuthorizationUri', () => {
jest
.spyOn(alipayNativeMethods, 'getConfig')
.mockResolvedValueOnce(mockedAlipayNativeConfigWithValidPrivateKey);
const authorizationUri = await alipayNativeMethods.getAuthorizationUri(
'dummy-state',
'dummy-redirect-uri'
);
const authorizationUri = await alipayNativeMethods.getAuthorizationUri({
state: 'dummy-state',
redirectUri: 'dummy-redirect-uri',
});
expect(authorizationUri).toBe('alipay://?app_id=2021000000000000&state=dummy-state');
});
});
@ -124,6 +124,27 @@ describe('getAccessToken', () => {
});
describe('getUserInfo', () => {
beforeEach(() => {
jest
.spyOn(alipayNativeMethods, 'getConfig')
.mockResolvedValueOnce(mockedAlipayNativeConfigWithValidPrivateKey);
const alipayEndpointUrl = new URL(alipayEndpoint);
nock(alipayEndpointUrl.origin)
.post(alipayEndpointUrl.pathname)
.query(true)
.reply(200, {
alipay_system_oauth_token_response: {
user_id: '2088000000000000',
access_token: 'access_token',
expires_in: '3600',
refresh_token: 'refresh_token',
re_expires_in: '7200', // Expiring time of refresh token, in seconds
},
sign: '<signature>',
});
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
@ -149,9 +170,7 @@ describe('getUserInfo', () => {
sign: '<signature>',
});
const { id, name, avatar } = await alipayNativeMethods.getUserInfo({
accessToken: 'access_token',
});
const { id, name, avatar } = await alipayNativeMethods.getUserInfo({ authCode: 'code' });
expect(id).toEqual('2088000000000000');
expect(name).toEqual('PlayboyEric');
expect(avatar).toEqual('https://www.alipay.com/xxx.jpg');
@ -173,9 +192,7 @@ describe('getUserInfo', () => {
sign: '<signature>',
});
await expect(
alipayNativeMethods.getUserInfo({ accessToken: 'wrong_access_token' })
).rejects.toMatchError(
await expect(alipayNativeMethods.getUserInfo({ authCode: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'Invalid auth token')
);
});
@ -196,9 +213,9 @@ describe('getUserInfo', () => {
sign: '<signature>',
});
await expect(
alipayNativeMethods.getUserInfo({ accessToken: 'wrong_access_token' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.General));
await expect(alipayNativeMethods.getUserInfo({ authCode: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General)
);
});
it('should throw with right accessToken but empty userInfo', async () => {
@ -219,9 +236,9 @@ describe('getUserInfo', () => {
sign: '<signature>',
});
await expect(
alipayNativeMethods.getUserInfo({ accessToken: 'access_token' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.InvalidResponse));
await expect(alipayNativeMethods.getUserInfo({ authCode: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
});
it('should throw with other request errors', async () => {
@ -230,8 +247,6 @@ describe('getUserInfo', () => {
.mockResolvedValueOnce(mockedAlipayNativeConfigWithValidPrivateKey);
nock(alipayEndpointUrl.origin).post(alipayEndpointUrl.pathname).query(true).reply(500);
await expect(
alipayNativeMethods.getUserInfo({ accessToken: 'access_token' })
).rejects.toThrow();
await expect(alipayNativeMethods.getUserInfo({ authCode: 'wrong_code' })).rejects.toThrow();
});
});

View file

@ -8,11 +8,9 @@
*/
import {
AccessTokenObject,
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
GetUserInfo,
ValidateConfig,
@ -22,6 +20,7 @@ import {
import { assert } from '@silverhand/essentials';
import dayjs from 'dayjs';
import got from 'got';
import { z } from 'zod';
import {
alipayEndpoint,
@ -41,19 +40,7 @@ import { signingParameters } from './utils';
export type { AlipayNativeConfig } from './types';
type CodePayload = {
auth_code: string;
};
const parseCodeFromJson = (json: string): string => {
try {
const { auth_code } = JSON.parse(json) as CodePayload;
return auth_code;
} catch {
return json;
}
};
const dataGuard = z.object({ authCode: z.string() });
export default class AlipayNativeConnector implements SocialConnector {
public metadata: ConnectorMetadata = defaultMetadata;
@ -70,7 +57,7 @@ export default class AlipayNativeConnector implements SocialConnector {
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, _) => {
public getAuthorizationUri: GetAuthorizationUri = async ({ state }) => {
const { appId } = await this.getConfig(this.metadata.id);
const queryParameters = new URLSearchParams({ app_id: appId, state });
@ -78,7 +65,7 @@ export default class AlipayNativeConnector implements SocialConnector {
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken: GetAccessToken = async (code): Promise<AccessTokenObject> => {
public getAccessToken = async (code: string) => {
const config = await this.getConfig(this.metadata.id);
const initSearchParameters = {
method: methodForAccessToken,
@ -86,7 +73,7 @@ export default class AlipayNativeConnector implements SocialConnector {
timestamp: dayjs().format('YYYY-MM-DD HH:mm:ss'),
version: '1.0',
grant_type: 'authorization_code',
code: parseCodeFromJson(code),
code,
charset: 'UTF8',
...config,
};
@ -111,9 +98,10 @@ export default class AlipayNativeConnector implements SocialConnector {
return { accessToken };
};
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
public getUserInfo: GetUserInfo = async (data) => {
const { authCode } = dataGuard.parse(data);
const config = await this.getConfig(this.metadata.id);
const { accessToken } = accessTokenObject;
const { accessToken } = await this.getAccessToken(authCode);
assert(
accessToken && config,
new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters)
@ -146,11 +134,11 @@ export default class AlipayNativeConnector implements SocialConnector {
sub_msg,
sub_code,
msg,
code,
code: responseCode,
} = response.alipay_user_info_share_response;
if (sub_msg || sub_code) {
if (code === '20001') {
if (responseCode === '20001') {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg);
}
throw new ConnectorError(ConnectorErrorCodes.General);

View file

@ -37,10 +37,10 @@ describe('getAuthorizationUri', () => {
it('should get a valid uri by redirectUri and state', async () => {
jest.spyOn(alipayMethods, 'getConfig').mockResolvedValueOnce(mockedAlipayConfig);
const authorizationUri = await alipayMethods.getAuthorizationUri(
'some_state',
'http://localhost:3001/callback'
);
const authorizationUri = await alipayMethods.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3001/callback',
});
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?app_id=2021000000000000&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback&scope=auth_user&state=some_state`
);
@ -124,6 +124,27 @@ describe('getAccessToken', () => {
});
describe('getUserInfo', () => {
beforeEach(() => {
jest
.spyOn(alipayMethods, 'getConfig')
.mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey);
const alipayEndpointUrl = new URL(alipayEndpoint);
nock(alipayEndpointUrl.origin)
.post(alipayEndpointUrl.pathname)
.query(true)
.reply(200, {
alipay_system_oauth_token_response: {
user_id: '2088000000000000',
access_token: 'access_token',
expires_in: '3600',
refresh_token: 'refresh_token',
re_expires_in: '7200', // Expiring time of refresh token, in seconds
},
sign: '<signature>',
});
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
@ -149,7 +170,7 @@ describe('getUserInfo', () => {
sign: '<signature>',
});
const { id, name, avatar } = await alipayMethods.getUserInfo({ accessToken: 'access_token' });
const { id, name, avatar } = await alipayMethods.getUserInfo({ code: 'code' });
expect(id).toEqual('2088000000000000');
expect(name).toEqual('PlayboyEric');
expect(avatar).toEqual('https://www.alipay.com/xxx.jpg');
@ -171,9 +192,7 @@ describe('getUserInfo', () => {
sign: '<signature>',
});
await expect(
alipayMethods.getUserInfo({ accessToken: 'wrong_access_token' })
).rejects.toMatchError(
await expect(alipayMethods.getUserInfo({ code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'Invalid auth token')
);
});
@ -194,9 +213,9 @@ describe('getUserInfo', () => {
sign: '<signature>',
});
await expect(
alipayMethods.getUserInfo({ accessToken: 'wrong_access_token' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.General));
await expect(alipayMethods.getUserInfo({ code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General)
);
});
it('should throw with right accessToken but empty userInfo', async () => {
@ -217,7 +236,7 @@ describe('getUserInfo', () => {
sign: '<signature>',
});
await expect(alipayMethods.getUserInfo({ accessToken: 'access_token' })).rejects.toMatchError(
await expect(alipayMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
});
@ -228,6 +247,6 @@ describe('getUserInfo', () => {
.mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey);
nock(alipayEndpointUrl.origin).post(alipayEndpointUrl.pathname).query(true).reply(500);
await expect(alipayMethods.getUserInfo({ accessToken: 'access_token' })).rejects.toThrow();
await expect(alipayMethods.getUserInfo({ code: 'code' })).rejects.toThrow();
});
});

View file

@ -6,16 +6,15 @@
*/
import {
AccessTokenObject,
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
GetUserInfo,
ValidateConfig,
SocialConnector,
GetConnectorConfig,
codeDataGuard,
} from '@logto/connector-types';
import { assert } from '@silverhand/essentials';
import dayjs from 'dayjs';
@ -64,7 +63,7 @@ export default class AlipayConnector implements SocialConnector {
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, redirectUri) => {
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const { appId: app_id } = await this.getConfig(this.metadata.id);
const redirect_uri = encodeURI(redirectUri);
@ -79,7 +78,7 @@ export default class AlipayConnector implements SocialConnector {
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken: GetAccessToken = async (code): Promise<AccessTokenObject> => {
public getAccessToken = async (code: string) => {
const config = await this.getConfig(this.metadata.id);
const initSearchParameters = {
method: methodForAccessToken,
@ -112,9 +111,11 @@ export default class AlipayConnector implements SocialConnector {
return { accessToken };
};
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
public getUserInfo: GetUserInfo = async (data) => {
const { code } = codeDataGuard.parse(data);
const config = await this.getConfig(this.metadata.id);
const { accessToken } = accessTokenObject;
const { accessToken } = await this.getAccessToken(code);
assert(
accessToken && config,
new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters)
@ -147,11 +148,11 @@ export default class AlipayConnector implements SocialConnector {
sub_msg,
sub_code,
msg,
code,
code: responseCode,
} = response.alipay_user_info_share_response;
if (sub_msg || sub_code) {
if (code === '20001') {
if (responseCode === '20001') {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg);
}
throw new ConnectorError(ConnectorErrorCodes.General);

View file

@ -1,5 +1,4 @@
import { GetConnectorConfig } from '@logto/connector-types';
import nock from 'nock';
import AppleConnector from '.';
import { authorizationEndpoint } from './constant';
@ -20,28 +19,16 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri by redirectUri and state', async () => {
const authorizationUri = await appleMethods.getAuthorizationUri(
'some_state',
'http://localhost:3000/callback'
);
const authorizationUri = await appleMethods.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3000/callback',
});
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=&state=some_state&response_type=code+id_token&response_mode=fragment`
);
});
});
describe('getAccessToken', () => {
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
it('should return code directly', async () => {
const accessToken = await appleMethods.getAccessToken('code');
expect(accessToken).toEqual('code');
});
});
describe('validateConfig', () => {
afterEach(() => {
jest.clearAllMocks();

View file

@ -1,6 +1,5 @@
import {
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
ValidateConfig,
GetUserInfo,
@ -12,10 +11,10 @@ import {
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { scope, defaultMetadata, jwksUri, issuer, authorizationEndpoint } from './constant';
import { appleConfigGuard, AppleConfig, appleDataGuard } from './types';
import { appleConfigGuard, AppleConfig, dataGuard } from './types';
// TO-DO: support nonce validation
export default class AppleConnector implements SocialConnector<string> {
export default class AppleConnector implements SocialConnector {
public metadata: ConnectorMetadata = defaultMetadata;
constructor(public readonly getConfig: GetConnectorConfig<AppleConfig>) {}
@ -28,7 +27,7 @@ export default class AppleConnector implements SocialConnector<string> {
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, redirectUri) => {
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
const queryParameters = new URLSearchParams({
@ -44,15 +43,8 @@ export default class AppleConnector implements SocialConnector<string> {
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
// Directly return now. Refactor with connector interface redesign.
getAccessToken: GetAccessToken<string> = async (code) => {
return code;
};
// Extract data from JSON string.
// Refactor with connector interface redesign.
public getUserInfo: GetUserInfo<string> = async (data) => {
const { id_token: idToken } = appleDataGuard.parse(JSON.parse(data));
public getUserInfo: GetUserInfo = async (data) => {
const { id_token: idToken } = dataGuard.parse(data);
if (!idToken) {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid);

View file

@ -7,8 +7,6 @@ export const appleConfigGuard = z.object({
export type AppleConfig = z.infer<typeof appleConfigGuard>;
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3331292
export const appleDataGuard = z.object({
export const dataGuard = z.object({
id_token: z.string(),
});
export type AppleData = z.infer<typeof appleDataGuard>;

View file

@ -41,7 +41,7 @@ describe('facebook connector', () => {
it('should get a valid authorizationUri with redirectUri and state', async () => {
const redirectUri = 'http://localhost:3000/callback';
const state = 'some_state';
const authorizationUri = await facebookMethods.getAuthorizationUri(state, redirectUri);
const authorizationUri = await facebookMethods.getAuthorizationUri({ state, redirectUri });
const encodedRedirectUri = encodeURIComponent(redirectUri);
expect(authorizationUri).toEqual(
@ -93,6 +93,22 @@ describe('facebook connector', () => {
});
describe('getUserInfo', () => {
beforeEach(() => {
nock(accessTokenEndpoint)
.get('')
.query({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: dummyRedirectUri,
})
.reply(200, {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
});
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
@ -110,7 +126,10 @@ describe('facebook connector', () => {
picture: { data: { url: avatar } },
});
const socialUserInfo = await facebookMethods.getUserInfo({ accessToken: code });
const socialUserInfo = await facebookMethods.getUserInfo({
code,
redirectUri: dummyRedirectUri,
});
expect(socialUserInfo).toMatchObject({
id: '1234567890',
avatar,
@ -121,14 +140,16 @@ describe('facebook connector', () => {
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).get('').query({ fields }).reply(400);
await expect(facebookMethods.getUserInfo({ accessToken: code })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
await expect(
facebookMethods.getUserInfo({ code, redirectUri: dummyRedirectUri })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
});
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).get('').reply(500);
await expect(facebookMethods.getUserInfo({ accessToken: code })).rejects.toThrow();
await expect(
facebookMethods.getUserInfo({ code, redirectUri: dummyRedirectUri })
).rejects.toThrow();
});
});
});

View file

@ -7,12 +7,12 @@ import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
GetUserInfo,
ValidateConfig,
SocialConnector,
GetConnectorConfig,
codeWithRedirectDataGuard,
} from '@logto/connector-types';
import { assert } from '@silverhand/essentials';
import got, { RequestError as GotRequestError } from 'got';
@ -45,7 +45,7 @@ export default class FacebookConnector implements SocialConnector {
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, redirectUri) => {
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
const queryParameters = new URLSearchParams({
@ -59,7 +59,7 @@ export default class FacebookConnector implements SocialConnector {
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken: GetAccessToken = async (code, redirectUri) => {
public getAccessToken = async (code: string, redirectUri: string) => {
const { clientId: client_id, clientSecret: client_secret } = await this.getConfig(
this.metadata.id
);
@ -81,8 +81,9 @@ export default class FacebookConnector implements SocialConnector {
return { accessToken };
};
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
const { accessToken } = accessTokenObject;
public getUserInfo: GetUserInfo = async (data) => {
const { code, redirectUri } = codeWithRedirectDataGuard.parse(data);
const { accessToken } = await this.getAccessToken(code, redirectUri);
try {
const { id, email, name, picture } = await got

View file

@ -20,10 +20,10 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri by redirectUri and state', async () => {
const authorizationUri = await githubMethods.getAuthorizationUri(
'some_state',
'http://localhost:3000/callback'
);
const authorizationUri = await githubMethods.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3000/callback',
});
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=some_state&scope=read%3Auser`
);
@ -75,6 +75,14 @@ describe('validateConfig', () => {
});
describe('getUserInfo', () => {
beforeEach(() => {
nock(accessTokenEndpoint).post('').reply(200, {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
});
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
@ -87,7 +95,7 @@ describe('getUserInfo', () => {
name: 'monalisa octocat',
email: 'octocat@github.com',
});
const socialUserInfo = await githubMethods.getUserInfo({ accessToken: 'code' });
const socialUserInfo = await githubMethods.getUserInfo({ code: 'code' });
expect(socialUserInfo).toMatchObject({
id: '1',
avatar: 'https://github.com/images/error/octocat_happy.gif',
@ -103,7 +111,7 @@ describe('getUserInfo', () => {
name: null,
email: null,
});
const socialUserInfo = await githubMethods.getUserInfo({ accessToken: 'code' });
const socialUserInfo = await githubMethods.getUserInfo({ code: 'code' });
expect(socialUserInfo).toMatchObject({
id: '1',
});
@ -111,13 +119,13 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).get('').reply(401);
await expect(githubMethods.getUserInfo({ accessToken: 'code' })).rejects.toMatchError(
await expect(githubMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).get('').reply(500);
await expect(githubMethods.getUserInfo({ accessToken: 'code' })).rejects.toThrow();
await expect(githubMethods.getUserInfo({ code: 'code' })).rejects.toThrow();
});
});

View file

@ -1,6 +1,5 @@
import {
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
ValidateConfig,
GetUserInfo,
@ -8,6 +7,7 @@ import {
ConnectorErrorCodes,
SocialConnector,
GetConnectorConfig,
codeDataGuard,
} from '@logto/connector-types';
import { assert, conditional } from '@silverhand/essentials';
import got, { RequestError as GotRequestError } from 'got';
@ -35,7 +35,7 @@ export default class GithubConnector implements SocialConnector {
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, redirectUri) => {
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
const queryParameters = new URLSearchParams({
@ -48,7 +48,7 @@ export default class GithubConnector implements SocialConnector {
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
getAccessToken: GetAccessToken = async (code) => {
getAccessToken = async (code: string) => {
const { clientId: client_id, clientSecret: client_secret } = await this.getConfig(
this.metadata.id
);
@ -70,8 +70,9 @@ export default class GithubConnector implements SocialConnector {
return { accessToken };
};
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
const { accessToken } = accessTokenObject;
public getUserInfo: GetUserInfo = async (data) => {
const { code } = codeDataGuard.parse(data);
const { accessToken } = await this.getAccessToken(code);
try {
const {

View file

@ -41,10 +41,10 @@ describe('google connector', () => {
});
it('should get a valid authorizationUri with redirectUri and state', async () => {
const authorizationUri = await googleMethods.getAuthorizationUri(
'some_state',
'http://localhost:3000/callback'
);
const authorizationUri = await googleMethods.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3000/callback',
});
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`
);
@ -76,6 +76,14 @@ describe('google connector', () => {
});
describe('getUserInfo', () => {
beforeEach(() => {
nock(accessTokenEndpoint).post('').reply(200, {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
});
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
@ -92,7 +100,7 @@ describe('google connector', () => {
email_verified: true,
locale: 'en',
});
const socialUserInfo = await googleMethods.getUserInfo({ accessToken: 'code' });
const socialUserInfo = await googleMethods.getUserInfo({ code: 'code', redirectUri: '' });
expect(socialUserInfo).toMatchObject({
id: '1234567890',
avatar: 'https://github.com/images/error/octocat_happy.gif',
@ -103,14 +111,14 @@ describe('google connector', () => {
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)
);
await expect(
googleMethods.getUserInfo({ code: 'code', redirectUri: '' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
});
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).post('').reply(500);
await expect(googleMethods.getUserInfo({ accessToken: 'code' })).rejects.toThrow();
await expect(googleMethods.getUserInfo({ code: 'code', redirectUri: '' })).rejects.toThrow();
});
});
});

View file

@ -5,13 +5,13 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetAccessToken,
GetAuthorizationUri,
GetUserInfo,
ConnectorMetadata,
ValidateConfig,
SocialConnector,
GetConnectorConfig,
codeWithRedirectDataGuard,
} from '@logto/connector-types';
import { conditional, assert } from '@silverhand/essentials';
import got, { RequestError as GotRequestError } from 'got';
@ -39,7 +39,7 @@ export default class GoogleConnector implements SocialConnector {
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, redirectUri) => {
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
const queryParameters = new URLSearchParams({
@ -53,7 +53,7 @@ export default class GoogleConnector implements SocialConnector {
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken: GetAccessToken = async (code, redirectUri) => {
public getAccessToken = async (code: string, redirectUri: string) => {
const { clientId, clientSecret } = await this.getConfig(this.metadata.id);
// NoteNeed to decodeURIComponent on code
@ -76,8 +76,9 @@ export default class GoogleConnector implements SocialConnector {
return { accessToken };
};
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
const { accessToken } = accessTokenObject;
public getUserInfo: GetUserInfo = async (data) => {
const { code, redirectUri } = codeWithRedirectDataGuard.parse(data);
const { accessToken } = await this.getAccessToken(code, redirectUri);
try {
const {

View file

@ -20,7 +20,8 @@
"node": "^16.0.0"
},
"dependencies": {
"@logto/phrases": "^0.1.0"
"@logto/phrases": "^0.1.0",
"zod": "^3.14.3"
},
"devDependencies": {
"@jest/types": "^27.5.1",

View file

@ -1,5 +1,6 @@
import { Language } from '@logto/phrases';
import { Nullable } from '@silverhand/essentials';
import { z } from 'zod';
export enum ConnectorType {
Email = 'Email',
@ -86,25 +87,33 @@ export interface EmailConnector extends BaseConnector {
sendMessage: EmailSendMessageFunction;
}
export interface SocialConnector<TokenObject = AccessTokenObject> extends BaseConnector {
export interface SocialConnector extends BaseConnector {
getAuthorizationUri: GetAuthorizationUri;
getAccessToken: GetAccessToken<TokenObject>;
getUserInfo: GetUserInfo<TokenObject>;
getUserInfo: GetUserInfo;
}
export type ValidateConfig<T = Record<string, unknown>> = (config: T) => Promise<void>;
export type GetAuthorizationUri = (state: string, redirectUri: string) => Promise<string>;
export type GetAuthorizationUri = (payload: {
state: string;
redirectUri: string;
}) => Promise<string>;
export type AccessTokenObject = { accessToken: string } & Record<string, string>;
export type GetAccessToken<TokenObject = AccessTokenObject> = (
code: string,
redirectUri?: string
) => Promise<TokenObject>;
export type GetUserInfo<TokenObject = AccessTokenObject> = (
accessTokenObject: TokenObject
export type GetUserInfo = (
data: unknown
) => Promise<{ id: string } & Record<string, string | undefined>>;
export type GetConnectorConfig<T = Record<string, unknown>> = (id: string) => Promise<T>;
export const codeDataGuard = z.object({
code: z.string(),
});
export type CodeData = z.infer<typeof codeDataGuard>;
export const codeWithRedirectDataGuard = z.object({
code: z.string(),
redirectUri: z.string(),
});
export type CodeWithRedirectData = z.infer<typeof codeWithRedirectDataGuard>;

View file

@ -20,10 +20,10 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri', async () => {
const authorizationUri = await weChatNativeMethods.getAuthorizationUri(
'dummy-state',
'dummy-redirect-uri'
);
const authorizationUri = await weChatNativeMethods.getAuthorizationUri({
state: 'dummy-state',
redirectUri: 'dummy-redirect-uri',
});
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?app_id=%3Capp-id%3E&state=dummy-state`
);
@ -82,14 +82,48 @@ describe('validateConfig', () => {
});
});
const nockNoOpenIdAccessTokenResponse = () => {
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
const parameters = new URLSearchParams({
appid: '<app-id>',
secret: '<app-secret>',
code: 'code',
grant_type: 'authorization_code',
});
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, {
access_token: 'access_token',
});
};
describe('getUserInfo', () => {
beforeEach(() => {
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
const parameters = new URLSearchParams({
appid: '<app-id>',
secret: '<app-secret>',
code: 'code',
grant_type: 'authorization_code',
});
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, {
access_token: 'access_token',
openid: 'openid',
});
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
const userInfoEndpointUrl = new URL(userInfoEndpoint);
const parameters = new URLSearchParams({ access_token: 'accessToken', openid: 'openid' });
const parameters = new URLSearchParams({ access_token: 'access_token', openid: 'openid' });
it('should get valid SocialUserInfo', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, {
@ -97,10 +131,7 @@ describe('getUserInfo', () => {
headimgurl: 'https://github.com/images/error/octocat_happy.gif',
nickname: 'wechat bot',
});
const socialUserInfo = await weChatNativeMethods.getUserInfo({
accessToken: 'accessToken',
openid: 'openid',
});
const socialUserInfo = await weChatNativeMethods.getUserInfo({ code: 'code' });
expect(socialUserInfo).toMatchObject({
id: 'this_is_an_arbitrary_wechat_union_id',
avatar: 'https://github.com/images/error/octocat_happy.gif',
@ -109,14 +140,16 @@ describe('getUserInfo', () => {
});
it('throws error if `openid` is missing', async () => {
nock.cleanAll();
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, {
unionid: 'this_is_an_arbitrary_wechat_union_id',
headimgurl: 'https://github.com/images/error/octocat_happy.gif',
nickname: 'wechat bot',
});
await expect(
weChatNativeMethods.getUserInfo({ accessToken: 'accessToken' })
).rejects.toMatchError(new Error('`openid` is required by WeChat API.'));
nockNoOpenIdAccessTokenResponse();
await expect(weChatNativeMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);
});
it('throws SocialAccessTokenInvalid error if errcode is 40001', async () => {
@ -124,16 +157,14 @@ describe('getUserInfo', () => {
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_001 });
await expect(
weChatNativeMethods.getUserInfo({ accessToken: 'accessToken', openid: 'openid' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
await expect(weChatNativeMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(500);
await expect(
weChatNativeMethods.getUserInfo({ accessToken: 'accessToken', openid: 'openid' })
).rejects.toThrow();
await expect(weChatNativeMethods.getUserInfo({ code: 'code' })).rejects.toThrow();
});
it('throws Error if request failed and errcode is not 40001', async () => {
@ -141,18 +172,15 @@ describe('getUserInfo', () => {
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_003, errmsg: 'invalid openid' });
await expect(
weChatNativeMethods.getUserInfo({ accessToken: 'accessToken', openid: 'openid' })
).rejects.toMatchError(new Error('invalid openid'));
await expect(weChatNativeMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new Error('invalid openid')
);
});
it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(new URLSearchParams({ access_token: 'wrongAccessToken', openid: 'openid' }))
.reply(401);
await expect(
weChatNativeMethods.getUserInfo({ accessToken: 'wrongAccessToken', openid: 'openid' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401);
await expect(weChatNativeMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
});

View file

@ -5,7 +5,6 @@
import {
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
ValidateConfig,
GetUserInfo,
@ -13,6 +12,7 @@ import {
ConnectorErrorCodes,
SocialConnector,
GetConnectorConfig,
codeDataGuard,
} from '@logto/connector-types';
import { assert } from '@silverhand/essentials';
import got, { RequestError as GotRequestError } from 'got';
@ -47,18 +47,21 @@ export default class WeChatNativeConnector implements SocialConnector {
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, _) => {
const { appId } = await this.getConfig(this.metadata.id);
public getAuthorizationUri: GetAuthorizationUri = async ({ state }) => {
const { appId, universalLinks } = await this.getConfig(this.metadata.id);
const queryParameters = new URLSearchParams({
app_id: appId,
state,
// `universalLinks` is used by Wechat open platform website,
// while `universal_link` is their API requirement.
...(universalLinks && { universal_link: universalLinks }),
});
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken: GetAccessToken = async (code) => {
public getAccessToken = async (code: string) => {
const { appId: appid, appSecret: secret } = await this.getConfig(this.metadata.id);
const {
@ -80,9 +83,11 @@ export default class WeChatNativeConnector implements SocialConnector {
return { accessToken, openid };
};
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
const { accessToken, openid } = accessTokenObject;
public getUserInfo: GetUserInfo = async (data) => {
const { code } = codeDataGuard.parse(data);
const { accessToken, openid } = await this.getAccessToken(code);
// TO-DO: @Darcy refactor this
// 'openid' is defined as a required input argument in WeChat API doc, but it does not necessarily have to
// be the return value from getAccessToken per testing.
// In other words, 'openid' is required but the response of getUserInfo is consistent as long as

View file

@ -1,6 +1,10 @@
import { z } from 'zod';
export const weChatNativeConfigGuard = z.object({ appId: z.string(), appSecret: z.string() });
export const weChatNativeConfigGuard = z.object({
appId: z.string(),
appSecret: z.string(),
universalLinks: z.string().optional(),
});
export type WeChatNativeConfig = z.infer<typeof weChatNativeConfigGuard>;

View file

@ -20,10 +20,10 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri by redirectUri and state', async () => {
const authorizationUri = await weChatMethods.getAuthorizationUri(
'some_state',
'http://localhost:3001/callback'
);
const authorizationUri = await weChatMethods.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3001/callback',
});
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?appid=%3Capp-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback&response_type=code&scope=snsapi_login&state=some_state`
);
@ -82,14 +82,47 @@ describe('validateConfig', () => {
});
});
const nockNoOpenIdAccessTokenResponse = () => {
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
const parameters = new URLSearchParams({
appid: '<app-id>',
secret: '<app-secret>',
code: 'code',
grant_type: 'authorization_code',
});
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, {
access_token: 'access_token',
});
};
describe('getUserInfo', () => {
beforeEach(() => {
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
const parameters = new URLSearchParams({
appid: '<app-id>',
secret: '<app-secret>',
code: 'code',
grant_type: 'authorization_code',
});
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, {
access_token: 'access_token',
openid: 'openid',
});
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
const userInfoEndpointUrl = new URL(userInfoEndpoint);
const parameters = new URLSearchParams({ access_token: 'accessToken', openid: 'openid' });
const parameters = new URLSearchParams({ access_token: 'access_token', openid: 'openid' });
it('should get valid SocialUserInfo', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, {
@ -98,8 +131,7 @@ describe('getUserInfo', () => {
nickname: 'wechat bot',
});
const socialUserInfo = await weChatMethods.getUserInfo({
accessToken: 'accessToken',
openid: 'openid',
code: 'code',
});
expect(socialUserInfo).toMatchObject({
id: 'this_is_an_arbitrary_wechat_union_id',
@ -109,13 +141,15 @@ describe('getUserInfo', () => {
});
it('throws error if `openid` is missing', async () => {
nock.cleanAll();
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, {
unionid: 'this_is_an_arbitrary_wechat_union_id',
headimgurl: 'https://github.com/images/error/octocat_happy.gif',
nickname: 'wechat bot',
});
await expect(weChatMethods.getUserInfo({ accessToken: 'accessToken' })).rejects.toMatchError(
new Error('`openid` is required by WeChat API.')
nockNoOpenIdAccessTokenResponse();
await expect(weChatMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);
});
@ -124,16 +158,14 @@ describe('getUserInfo', () => {
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_001 });
await expect(
weChatMethods.getUserInfo({ accessToken: 'accessToken', openid: 'openid' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
await expect(weChatMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(500);
await expect(
weChatMethods.getUserInfo({ accessToken: 'accessToken', openid: 'openid' })
).rejects.toThrow();
await expect(weChatMethods.getUserInfo({ code: 'code' })).rejects.toThrow();
});
it('throws Error if request failed and errcode is not 40001', async () => {
@ -141,18 +173,15 @@ describe('getUserInfo', () => {
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_003, errmsg: 'invalid openid' });
await expect(
weChatMethods.getUserInfo({ accessToken: 'accessToken', openid: 'openid' })
).rejects.toMatchError(new Error('invalid openid'));
await expect(weChatMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new Error('invalid openid')
);
});
it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(new URLSearchParams({ access_token: 'wrongAccessToken', openid: 'openid' }))
.reply(401);
await expect(
weChatMethods.getUserInfo({ accessToken: 'wrongAccessToken', openid: 'openid' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401);
await expect(weChatMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
});

View file

@ -5,7 +5,6 @@
import {
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
ValidateConfig,
GetUserInfo,
@ -13,6 +12,7 @@ import {
ConnectorErrorCodes,
SocialConnector,
GetConnectorConfig,
codeDataGuard,
} from '@logto/connector-types';
import { assert } from '@silverhand/essentials';
import got, { RequestError as GotRequestError } from 'got';
@ -43,7 +43,7 @@ export default class WeChatConnector implements SocialConnector {
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, redirectUri) => {
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const { appId } = await this.getConfig(this.metadata.id);
const queryParameters = new URLSearchParams({
@ -57,7 +57,7 @@ export default class WeChatConnector implements SocialConnector {
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken: GetAccessToken = async (code) => {
public getAccessToken = async (code: string) => {
const { appId: appid, appSecret: secret } = await this.getConfig(this.metadata.id);
const {
@ -79,9 +79,11 @@ export default class WeChatConnector implements SocialConnector {
return { accessToken, openid };
};
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
const { accessToken, openid } = accessTokenObject;
public getUserInfo: GetUserInfo = async (data) => {
const { code } = codeDataGuard.parse(data);
const { accessToken, openid } = await this.getAccessToken(code);
// TO-DO: @Darcy refactor this
// 'openid' is defined as a required input argument in WeChat API doc, but it does not necessarily have to
// be the return value from getAccessToken per testing.
// In other words, 'openid' is required but the response of getUserInfo is consistent as long as

View file

@ -37,19 +37,11 @@ const getConnector = async (connectorId: string) => {
export const getUserInfoByAuthCode = async (
connectorId: string,
authCode: string,
redirectUri: string
data: unknown
): Promise<SocialUserInfo> => {
// TO-DO: rename and refactor connector methods
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const data = JSON.parse(authCode);
const connector = await getConnector(connectorId);
const accessTokenObject = await connector.getAccessToken(
Object.keys(data).length > 1 ? authCode : data.code,
redirectUri
);
return connector.getUserInfo(accessTokenObject);
return connector.getUserInfo(data);
};
export const getUserInfoFromInteractionResult = async (

View file

@ -42,7 +42,7 @@ jest.mock('@/lib/social', () => ({
async findSocialRelatedUser() {
return ['phone', { id: 'user1', identities: {} }];
},
async getUserInfoByAuthCode(connectorId: string, authCode: string) {
async getUserInfoByAuthCode(connectorId: string, data: { code: string }) {
if (connectorId === '_connectorId') {
throw new RequestError({
code: 'session.invalid_connector_id',
@ -51,7 +51,7 @@ jest.mock('@/lib/social', () => ({
});
}
if (authCode === '123456') {
if (data.code === '123456') {
return { id: 'id' };
}
@ -410,9 +410,11 @@ describe('sessionRoutes', () => {
it('get and add user info with auth code, as well as assign result and redirect', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: 'connectorId',
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
data: {
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
},
});
expect(updateUserById).toHaveBeenCalledWith(
'id',
@ -432,9 +434,11 @@ describe('sessionRoutes', () => {
it('throw error when identity exists', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: '_connectorId_',
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
data: {
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
},
});
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),

View file

@ -13,7 +13,7 @@ import {
import { conditional } from '@silverhand/essentials';
import pick from 'lodash.pick';
import { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import { object, string, unknown } from 'zod';
import { getSocialConnectorInstanceById } from '@/connectors';
import RequestError from '@/errors/RequestError';
@ -196,7 +196,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
assertThat(state && redirectUri, 'session.insufficient_info');
const connector = await getSocialConnectorInstanceById(connectorId);
assertThat(connector.connector.enabled, 'connector.not_enabled');
const redirectTo = await connector.getAuthorizationUri(state, redirectUri);
const redirectTo = await connector.getAuthorizationUri({ state, redirectUri });
ctx.body = { redirectTo };
return next();
@ -208,16 +208,15 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
koaGuard({
body: object({
connectorId: string(),
code: string(),
redirectUri: string().regex(redirectUriRegEx),
data: unknown(),
}),
}),
async (ctx, next) => {
const { connectorId, code, redirectUri } = ctx.guard.body;
const { connectorId, data } = ctx.guard.body;
const type = 'SignInSocial';
ctx.log(type, { connectorId, code, redirectUri });
ctx.log(type, { connectorId, data });
const userInfo = await getUserInfoByAuthCode(connectorId, code, redirectUri);
const userInfo = await getUserInfoByAuthCode(connectorId, data);
ctx.log(type, { userInfo });
if (!(await hasUserWithIdentity(connectorId, userInfo.id))) {

View file

@ -193,8 +193,10 @@ describe('api', () => {
it('signInWithSocial', async () => {
const parameters = {
connectorId: 'connectorId',
redirectUri: 'redirectUri',
code: 'code',
data: {
redirectUri: 'redirectUri',
code: 'code',
},
};
await signInWithSocial(parameters);
expect(ky.post).toBeCalledWith('/api/session/sign-in/social/auth', {

View file

@ -20,18 +20,14 @@ export const invokeSocialSignIn = async (
.json<Response>();
};
export const signInWithSocial = async (parameters: {
connectorId: string;
redirectUri: string;
code: string;
}) => {
export const signInWithSocial = async (json: { connectorId: string; data: unknown }) => {
type Response = {
redirectTo: string;
};
return api
.post('/api/session/sign-in/social/auth', {
json: parameters,
json,
})
.json<Response>();
};

View file

@ -37,11 +37,13 @@ const useSocialSignInListener = () => {
);
const signInWithSocialHandler = useCallback(
async (connectorId: string, code: string) => {
async (connectorId: string, data: Record<string, unknown>) => {
void asyncSignInWithSocial({
connectorId,
code,
redirectUri: `${window.location.origin}/callback/${connectorId}`, // For validation use only
data: {
redirectUri: `${window.location.origin}/callback/${connectorId}`, // For validation use only
...data,
},
});
},
[asyncSignInWithSocial]
@ -67,7 +69,7 @@ const useSocialSignInListener = () => {
return;
}
void signInWithSocialHandler(parameters.connector, JSON.stringify(rest));
void signInWithSocialHandler(parameters.connector, rest);
}, [parameters.connector, setToast, signInWithSocialHandler, t]);
};

View file

@ -7,7 +7,9 @@ export const parseQueryParameters = (parameters: string | URLSearchParams) => {
const searchParameters =
parameters instanceof URLSearchParams ? parameters : new URLSearchParams(parameters);
return Object.fromEntries(searchParameters);
return Object.fromEntries(
[...searchParameters.entries()].map(([key, value]) => [key, decodeURIComponent(value)])
);
};
export const queryStringify = (parameters: URLSearchParams | Record<string, string>) => {

115
pnpm-lock.yaml generated
View file

@ -516,8 +516,10 @@ importers:
prettier: ^2.3.2
ts-jest: ^27.1.1
typescript: ^4.6.2
zod: ^3.14.3
dependencies:
'@logto/phrases': link:../phrases
zod: 3.14.3
devDependencies:
'@jest/types': 27.5.1
'@shopify/jest-koa-mocks': 4.0.0
@ -3889,6 +3891,7 @@ packages:
pacote: 13.4.1
semver: 7.3.7
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -3919,6 +3922,7 @@ packages:
p-waterfall: 2.1.1
semver: 7.3.7
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -4059,6 +4063,7 @@ packages:
whatwg-url: 8.7.0
yargs-parser: 20.2.4
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -4256,6 +4261,7 @@ packages:
npm-registry-fetch: 9.0.0
npmlog: 4.1.2
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -4285,6 +4291,7 @@ packages:
pify: 5.0.0
read-package-json: 3.0.1
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -4323,6 +4330,7 @@ packages:
npmlog: 4.1.2
tar: 6.1.11
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -4421,6 +4429,7 @@ packages:
pacote: 13.4.1
semver: 7.3.7
transitivePeerDependencies:
- bluebird
- encoding
- supports-color
dev: true
@ -4466,6 +4475,7 @@ packages:
'@npmcli/run-script': 3.0.2
npmlog: 4.1.2
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -4567,6 +4577,7 @@ packages:
slash: 3.0.0
write-json-file: 4.3.0
transitivePeerDependencies:
- bluebird
- encoding
- supports-color
dev: true
@ -4750,6 +4761,7 @@ packages:
treeverse: 2.0.0
walk-up-path: 1.0.0
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -4785,6 +4797,8 @@ packages:
promise-retry: 2.0.1
semver: 7.3.7
which: 2.0.2
transitivePeerDependencies:
- bluebird
dev: true
/@npmcli/installed-package-contents/1.0.7:
@ -4815,6 +4829,7 @@ packages:
pacote: 13.4.1
semver: 7.3.7
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -4866,6 +4881,7 @@ packages:
node-gyp: 9.0.0
read-package-json-fast: 2.0.3
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -5856,6 +5872,7 @@ packages:
stylelint-config-xo-scss: 0.15.0_zhymizk4kfitko2u2d4p3qwyee
transitivePeerDependencies:
- eslint
- eslint-import-resolver-webpack
- postcss
- prettier
- supports-color
@ -5879,7 +5896,7 @@ packages:
eslint-import-resolver-typescript: 2.5.0_rnagsyfcubvpoxo2ynj23pim7u
eslint-plugin-consistent-default-export-name: 0.0.7
eslint-plugin-eslint-comments: 3.2.0_eslint@8.10.0
eslint-plugin-import: 2.25.4_eslint@8.10.0
eslint-plugin-import: 2.25.4_sidoke6kqbkbdht6nlmwbfnany
eslint-plugin-no-use-extend-native: 0.5.0
eslint-plugin-node: 11.1.0_eslint@8.10.0
eslint-plugin-prettier: 3.4.1_6pitu4b2tqihty6rv5qeiyb35m
@ -5889,6 +5906,7 @@ packages:
pkg-dir: 4.2.0
prettier: 2.5.1
transitivePeerDependencies:
- eslint-import-resolver-webpack
- supports-color
- typescript
dev: true
@ -5910,7 +5928,7 @@ packages:
eslint-import-resolver-typescript: 2.5.0_rnagsyfcubvpoxo2ynj23pim7u
eslint-plugin-consistent-default-export-name: 0.0.7
eslint-plugin-eslint-comments: 3.2.0_eslint@8.10.0
eslint-plugin-import: 2.25.4_eslint@8.10.0
eslint-plugin-import: 2.25.4_sidoke6kqbkbdht6nlmwbfnany
eslint-plugin-no-use-extend-native: 0.5.0
eslint-plugin-node: 11.1.0_eslint@8.10.0
eslint-plugin-prettier: 3.4.1_6pitu4b2tqihty6rv5qeiyb35m
@ -5920,6 +5938,7 @@ packages:
pkg-dir: 4.2.0
prettier: 2.5.1
transitivePeerDependencies:
- eslint-import-resolver-webpack
- supports-color
- typescript
dev: true
@ -5941,7 +5960,7 @@ packages:
eslint-import-resolver-typescript: 2.5.0_rnagsyfcubvpoxo2ynj23pim7u
eslint-plugin-consistent-default-export-name: 0.0.7
eslint-plugin-eslint-comments: 3.2.0_eslint@8.10.0
eslint-plugin-import: 2.25.4_eslint@8.10.0
eslint-plugin-import: 2.25.4_sidoke6kqbkbdht6nlmwbfnany
eslint-plugin-no-use-extend-native: 0.5.0
eslint-plugin-node: 11.1.0_eslint@8.10.0
eslint-plugin-prettier: 3.4.1_6pitu4b2tqihty6rv5qeiyb35m
@ -5951,6 +5970,7 @@ packages:
pkg-dir: 4.2.0
prettier: 2.5.1
transitivePeerDependencies:
- eslint-import-resolver-webpack
- supports-color
- typescript
dev: true
@ -8020,6 +8040,8 @@ packages:
qs: 6.9.7
raw-body: 2.4.3
type-is: 1.6.18
transitivePeerDependencies:
- supports-color
dev: true
/bonjour-service/1.0.11:
@ -8202,6 +8224,8 @@ packages:
ssri: 8.0.1
tar: 6.1.11
unique-filename: 1.1.1
transitivePeerDependencies:
- bluebird
dev: true
/cacache/16.1.0:
@ -8226,6 +8250,8 @@ packages:
ssri: 9.0.1
tar: 6.1.11
unique-filename: 1.1.1
transitivePeerDependencies:
- bluebird
dev: true
/cache-content-type/1.0.1:
@ -8758,6 +8784,8 @@ packages:
on-headers: 1.0.2
safe-buffer: 5.1.2
vary: 1.1.2
transitivePeerDependencies:
- supports-color
dev: true
/concat-map/0.0.1:
@ -9307,12 +9335,22 @@ packages:
/debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.0.0
dev: true
/debug/3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.1.3
@ -9540,6 +9578,8 @@ packages:
dependencies:
address: 1.1.2
debug: 2.6.9
transitivePeerDependencies:
- supports-color
dev: true
/detect-port/1.3.0:
@ -9549,6 +9589,8 @@ packages:
dependencies:
address: 1.1.2
debug: 2.6.9
transitivePeerDependencies:
- supports-color
dev: true
/dezalgo/1.0.3:
@ -10020,6 +10062,8 @@ packages:
dependencies:
debug: 3.2.7
resolve: 1.22.0
transitivePeerDependencies:
- supports-color
dev: true
/eslint-import-resolver-typescript/2.5.0_rnagsyfcubvpoxo2ynj23pim7u:
@ -10031,7 +10075,7 @@ packages:
dependencies:
debug: 4.3.3
eslint: 8.10.0
eslint-plugin-import: 2.25.4_eslint@8.10.0
eslint-plugin-import: 2.25.4_sidoke6kqbkbdht6nlmwbfnany
glob: 7.2.0
is-glob: 4.0.3
resolve: 1.22.0
@ -10040,12 +10084,31 @@ packages:
- supports-color
dev: true
/eslint-module-utils/2.7.3:
/eslint-module-utils/2.7.3_l62aq42yiamaj3cnpuf6avthf4:
resolution: {integrity: sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint-import-resolver-node: '*'
eslint-import-resolver-typescript: '*'
eslint-import-resolver-webpack: '*'
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
eslint-import-resolver-node:
optional: true
eslint-import-resolver-typescript:
optional: true
eslint-import-resolver-webpack:
optional: true
dependencies:
'@typescript-eslint/parser': 5.14.0_fo4uz55zgcu432252zy2gvpvcu
debug: 3.2.7
eslint-import-resolver-node: 0.3.6
eslint-import-resolver-typescript: 2.5.0_rnagsyfcubvpoxo2ynj23pim7u
find-up: 2.1.0
transitivePeerDependencies:
- supports-color
dev: true
/eslint-plugin-consistent-default-export-name/0.0.7:
@ -10080,19 +10143,24 @@ packages:
ignore: 5.2.0
dev: true
/eslint-plugin-import/2.25.4_eslint@8.10.0:
/eslint-plugin-import/2.25.4_sidoke6kqbkbdht6nlmwbfnany:
resolution: {integrity: sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 5.14.0_fo4uz55zgcu432252zy2gvpvcu
array-includes: 3.1.4
array.prototype.flat: 1.2.5
debug: 2.6.9
doctrine: 2.1.0
eslint: 8.10.0
eslint-import-resolver-node: 0.3.6
eslint-module-utils: 2.7.3
eslint-module-utils: 2.7.3_l62aq42yiamaj3cnpuf6avthf4
has: 1.0.3
is-core-module: 2.8.1
is-glob: 4.0.3
@ -10100,6 +10168,10 @@ packages:
object.values: 1.1.5
resolve: 1.22.0
tsconfig-paths: 3.13.0
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
dev: true
/eslint-plugin-no-use-extend-native/0.5.0:
@ -10491,6 +10563,8 @@ packages:
type-is: 1.6.18
utils-merge: 1.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
dev: true
/extend-shallow/2.0.1:
@ -10682,6 +10756,8 @@ packages:
parseurl: 1.3.3
statuses: 1.5.0
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
dev: true
/find-cache-dir/3.3.2:
@ -13388,6 +13464,7 @@ packages:
import-local: 3.1.0
npmlog: 4.1.2
transitivePeerDependencies:
- bluebird
- encoding
- supports-color
dev: true
@ -13422,6 +13499,7 @@ packages:
npm-package-arg: 8.1.5
npm-registry-fetch: 11.0.0
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -13435,6 +13513,7 @@ packages:
semver: 7.3.7
ssri: 8.0.1
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -13865,6 +13944,7 @@ packages:
socks-proxy-agent: 6.1.1
ssri: 9.0.1
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -13888,6 +13968,7 @@ packages:
socks-proxy-agent: 5.0.1
ssri: 8.0.1
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -13912,6 +13993,7 @@ packages:
socks-proxy-agent: 6.1.1
ssri: 8.0.1
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -14816,6 +14898,7 @@ packages:
tar: 6.1.11
which: 2.0.2
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -14987,6 +15070,7 @@ packages:
minizlib: 2.1.2
npm-package-arg: 8.1.5
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -15002,6 +15086,7 @@ packages:
npm-package-arg: 9.0.2
proc-log: 2.0.1
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -15018,6 +15103,7 @@ packages:
minizlib: 2.1.2
npm-package-arg: 8.1.5
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -15443,6 +15529,7 @@ packages:
ssri: 9.0.1
tar: 6.1.11
transitivePeerDependencies:
- bluebird
- supports-color
dev: true
@ -15852,6 +15939,8 @@ packages:
async: 2.6.3
debug: 3.2.7
mkdirp: 0.5.5
transitivePeerDependencies:
- supports-color
dev: true
/postcss-calc/8.2.4_postcss@8.4.12:
@ -16551,6 +16640,11 @@ packages:
/promise-inflight/1.0.1:
resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=}
peerDependencies:
bluebird: '*'
peerDependenciesMeta:
bluebird:
optional: true
dev: true
/promise-retry/2.0.1:
@ -16823,6 +16917,7 @@ packages:
text-table: 0.2.0
transitivePeerDependencies:
- eslint
- supports-color
- typescript
- vue-template-compiler
- webpack
@ -17901,6 +17996,8 @@ packages:
on-finished: 2.3.0
range-parser: 1.2.1
statuses: 1.5.0
transitivePeerDependencies:
- supports-color
dev: true
/serialize-error/7.0.1:
@ -17947,6 +18044,8 @@ packages:
http-errors: 1.6.3
mime-types: 2.1.35
parseurl: 1.3.3
transitivePeerDependencies:
- supports-color
dev: true
/serve-static/1.14.2:
@ -17957,6 +18056,8 @@ packages:
escape-html: 1.0.3
parseurl: 1.3.3
send: 0.17.2
transitivePeerDependencies:
- supports-color
dev: true
/set-blocking/2.0.0: