mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): add management APIs to help with linking social identities to user (#3821)
* feat(core): add management APIs to help with linking social identities to user * chore: add changeset * test: add integration tests * chore: improve wording Co-authored-by: Darcy Ye <darcyye@silverhand.io> * chore: improve wording Co-authored-by: Darcy Ye <darcyye@silverhand.io> * refactor: return user identities after update * chore: rename test filename * chore: add code annotation --------- Co-authored-by: Darcy Ye <darcyye@silverhand.io>
This commit is contained in:
parent
30cd7727de
commit
0023dfe38a
12 changed files with 593 additions and 82 deletions
8
.changeset/nice-monkeys-hope.md
Normal file
8
.changeset/nice-monkeys-hope.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"@logto/core": minor
|
||||
---
|
||||
|
||||
Provide management APIs to help link social identities to user
|
||||
|
||||
- POST `/users/:userId/identities` to link a social identity to a user
|
||||
- POST `/connectors/:connectorId/authorization-uri` to get the authorization URI for a connector
|
208
packages/core/src/routes/admin-user-social.test.ts
Normal file
208
packages/core/src/routes/admin-user-social.test.ts
Normal file
|
@ -0,0 +1,208 @@
|
|||
import { ConnectorType, type CreateUser, type User } from '@logto/schemas';
|
||||
import { pickDefault } from '@logto/shared/esm';
|
||||
|
||||
import {
|
||||
mockConnector0,
|
||||
mockLogtoConnector,
|
||||
mockLogtoConnectorList,
|
||||
mockMetadata0,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import { mockUser } from '#src/__mocks__/user.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const notExistedUserId = 'notExistedUserId';
|
||||
|
||||
const mockHasUserWithIdentity = jest.fn(async () => false);
|
||||
const mockedQueries = {
|
||||
users: {
|
||||
findUserById: jest.fn(async (id: string) => {
|
||||
if (id === notExistedUserId) {
|
||||
throw new RequestError({ code: 'entity.not_exists', status: 404 });
|
||||
}
|
||||
return mockUser;
|
||||
}),
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
...data,
|
||||
})
|
||||
),
|
||||
hasUserWithIdentity: mockHasUserWithIdentity,
|
||||
deleteUserById: jest.fn(),
|
||||
deleteUserIdentity: jest.fn(),
|
||||
},
|
||||
} satisfies Partial2<Queries>;
|
||||
|
||||
const usersLibraries = {
|
||||
generateUserId: jest.fn(async () => 'fooId'),
|
||||
insertUser: jest.fn(
|
||||
async (user: CreateUser): Promise<User> => ({
|
||||
...mockUser,
|
||||
...user,
|
||||
})
|
||||
),
|
||||
} satisfies Partial<Libraries['users']>;
|
||||
|
||||
const mockGetLogtoConnectors = jest.fn(async () => mockLogtoConnectorList);
|
||||
const mockedConnectors = {
|
||||
getLogtoConnectors: mockGetLogtoConnectors,
|
||||
getLogtoConnectorById: async (connectorId: string) => {
|
||||
const connectors = await mockGetLogtoConnectors();
|
||||
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
|
||||
assertThat(
|
||||
connector,
|
||||
new RequestError({
|
||||
code: 'entity.not_found',
|
||||
connectorId,
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
return connector;
|
||||
},
|
||||
};
|
||||
|
||||
const { findUserById, updateUserById, deleteUserIdentity } = mockedQueries.users;
|
||||
|
||||
const adminUserSocialRoutes = await pickDefault(import('./admin-user-social.js'));
|
||||
|
||||
describe('Admin user social identities APIs', () => {
|
||||
const tenantContext = new MockTenant(undefined, mockedQueries, mockedConnectors, {
|
||||
users: usersLibraries,
|
||||
});
|
||||
|
||||
const userRequest = createRequester({ authedRoutes: adminUserSocialRoutes, tenantContext });
|
||||
|
||||
describe('POST /users/:userId/identities', () => {
|
||||
it('should throw if user cannot be found', async () => {
|
||||
// Mock connector with id 'id0' is declared in mockLogtoConnectorList
|
||||
await expect(
|
||||
userRequest
|
||||
.post(`/users/${notExistedUserId}/identities`)
|
||||
.send({ connectorId: 'id0', connectorData: { code: 'random_code' } })
|
||||
).resolves.toHaveProperty('status', 404);
|
||||
expect(updateUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw if user is found but connector cannot be found', async () => {
|
||||
const nonExistConnectorId = 'non_exist_connector_id';
|
||||
await expect(
|
||||
userRequest
|
||||
.post(`/users/foo/identities`)
|
||||
.send({ connectorId: nonExistConnectorId, connectorData: { code: 'random_code' } })
|
||||
).resolves.toHaveProperty('status', 404);
|
||||
expect(updateUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw if connector type is not social', async () => {
|
||||
// Mock connector with id 'id1' is declared in mockLogtoConnectorList, whose type is sms (not social)
|
||||
await expect(
|
||||
userRequest
|
||||
.post(`/users/foo/identities`)
|
||||
.send({ connectorId: 'id1', connectorData: { code: 'random_code' } })
|
||||
).resolves.toHaveProperty('status', 422);
|
||||
expect(updateUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw if user already has the social identity', async () => {
|
||||
mockHasUserWithIdentity.mockResolvedValueOnce(true);
|
||||
mockGetLogtoConnectors.mockResolvedValueOnce([
|
||||
{
|
||||
dbEntry: mockConnector0,
|
||||
metadata: { ...mockMetadata0 },
|
||||
type: ConnectorType.Social,
|
||||
...mockLogtoConnector,
|
||||
getAuthorizationUri: async () => 'http://example.com',
|
||||
getUserInfo: async () => ({ id: 'foo' }),
|
||||
},
|
||||
]);
|
||||
const mockedFindUserById = findUserById as jest.Mock;
|
||||
mockedFindUserById.mockImplementationOnce(() => ({
|
||||
...mockUser,
|
||||
identities: { connector_0: {} }, // This value 'connector_0' is declared in mockLogtoConnectorList
|
||||
}));
|
||||
await expect(
|
||||
userRequest
|
||||
.post(`/users/foo/identities`)
|
||||
.send({ connectorId: 'id0', connectorData: { code: 'random_code' } })
|
||||
).resolves.toHaveProperty('status', 422);
|
||||
expect(updateUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update user with new social identity', async () => {
|
||||
const mockedSocialUserInfo = { id: 'socialId' };
|
||||
mockGetLogtoConnectors.mockResolvedValueOnce([
|
||||
{
|
||||
dbEntry: mockConnector0,
|
||||
metadata: { ...mockMetadata0 },
|
||||
type: ConnectorType.Social,
|
||||
...mockLogtoConnector,
|
||||
getAuthorizationUri: async () => 'http://example.com',
|
||||
getUserInfo: async () => mockedSocialUserInfo,
|
||||
},
|
||||
]);
|
||||
const mockedFindUserById = findUserById as jest.Mock;
|
||||
mockedFindUserById.mockImplementationOnce(() => ({
|
||||
...mockUser,
|
||||
identities: { connectorTarget1: { userId: 'socialIdForTarget1' } },
|
||||
}));
|
||||
await expect(
|
||||
userRequest
|
||||
.post(`/users/foo/identities`)
|
||||
.send({ connectorId: 'id0', connectorData: { code: 'random_code' } })
|
||||
).resolves.toHaveProperty('status', 200);
|
||||
expect(updateUserById).toHaveBeenCalledWith('foo', {
|
||||
identities: {
|
||||
connectorTarget1: { userId: 'socialIdForTarget1' },
|
||||
connector_0: { userId: 'socialId', details: mockedSocialUserInfo },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/:userId/identities/:target', () => {
|
||||
it('should throw if user cannot be found', async () => {
|
||||
const arbitraryTarget = 'arbitraryTarget';
|
||||
await expect(
|
||||
userRequest.delete(`/users/foo/identities/${arbitraryTarget}`)
|
||||
).resolves.toHaveProperty('status', 404);
|
||||
expect(deleteUserIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw if user is found but connector cannot be found', async () => {
|
||||
const arbitraryUserId = 'arbitraryUserId';
|
||||
const nonExistedTarget = 'nonExistedTarget';
|
||||
const mockedFindUserById = findUserById as jest.Mock;
|
||||
mockedFindUserById.mockImplementationOnce((userId) => {
|
||||
if (userId === arbitraryUserId) {
|
||||
return { identities: { connector1: {}, connector2: {} } };
|
||||
}
|
||||
});
|
||||
await expect(
|
||||
userRequest.delete(`/users/${arbitraryUserId}/identities/${nonExistedTarget}`)
|
||||
).resolves.toHaveProperty('status', 404);
|
||||
expect(deleteUserIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete identity from user', async () => {
|
||||
const arbitraryUserId = 'arbitraryUserId';
|
||||
const arbitraryTarget = 'arbitraryTarget';
|
||||
const mockedFindUserById = findUserById as jest.Mock;
|
||||
mockedFindUserById.mockImplementationOnce((userId) => {
|
||||
if (userId === arbitraryUserId) {
|
||||
return {
|
||||
identities: { connectorTarget1: {}, connectorTarget2: {}, arbitraryTarget: {} },
|
||||
};
|
||||
}
|
||||
});
|
||||
await userRequest.delete(`/users/${arbitraryUserId}/identities/${arbitraryTarget}`);
|
||||
expect(deleteUserIdentity).toHaveBeenCalledWith(arbitraryUserId, arbitraryTarget);
|
||||
});
|
||||
});
|
||||
});
|
128
packages/core/src/routes/admin-user-social.ts
Normal file
128
packages/core/src/routes/admin-user-social.ts
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { notImplemented } from '@logto/cli/lib/connector/consts.js';
|
||||
import {
|
||||
ConnectorType,
|
||||
identitiesGuard,
|
||||
userInfoSelectFields,
|
||||
userProfileResponseGuard,
|
||||
} from '@logto/schemas';
|
||||
import { has, pick } from '@silverhand/essentials';
|
||||
import { object, record, string, unknown } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
export default function adminUserSocialRoutes<T extends AuthedRouter>(
|
||||
...[router, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
queries: {
|
||||
users: { findUserById, updateUserById, hasUserWithIdentity, deleteUserIdentity },
|
||||
},
|
||||
connectors: { getLogtoConnectorById },
|
||||
} = tenant;
|
||||
|
||||
/**
|
||||
* Link authenticated user identity from a social platform to a Logto user. The usage of this API is usually
|
||||
* coupled with `POST /connectors/:connectorId/authorization-uri`. With the help of these pair of APIs, you
|
||||
* can implement a user profile page with the `Link Social` feature in your application.
|
||||
*
|
||||
* Note: Currently due to technical limitations, this API does not support the following connectors that
|
||||
* rely on Logto interaction session: `@logto/connector-apple`, `@logto/connector-saml`, `@logto/connector-oidc`
|
||||
* and `@logto/connector-oauth`.
|
||||
*
|
||||
* @param {string} userId - The id of the Logto user
|
||||
* @param {string} connectorId - The id of the connector
|
||||
* @param {object} connectorData - A json object constructed from the url query params returned by the social
|
||||
* platform. Typically it contains `code`, `state` and `redirectUri` fields.
|
||||
*/
|
||||
router.post(
|
||||
'/users/:userId/identities',
|
||||
koaGuard({
|
||||
params: object({ userId: string() }),
|
||||
body: object({
|
||||
connectorId: string(),
|
||||
connectorData: record(string(), unknown()),
|
||||
}),
|
||||
response: identitiesGuard,
|
||||
status: [200, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { userId },
|
||||
body: { connectorId, connectorData },
|
||||
} = ctx.guard;
|
||||
|
||||
const [connector, user] = await Promise.all([
|
||||
getLogtoConnectorById(connectorId),
|
||||
findUserById(userId),
|
||||
]);
|
||||
|
||||
assertThat(
|
||||
connector.type === ConnectorType.Social,
|
||||
new RequestError({
|
||||
code: 'session.invalid_connector_id',
|
||||
status: 422,
|
||||
connectorId,
|
||||
})
|
||||
);
|
||||
|
||||
const { target } = connector.metadata;
|
||||
|
||||
/**
|
||||
* Same as above, passing `notImplemented` only works for connectors not relying on session storage.
|
||||
* E.g. Google and GitHub
|
||||
*/
|
||||
const socialUserInfo = await connector.getUserInfo(connectorData, notImplemented);
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithIdentity(target, socialUserInfo.id, userId)),
|
||||
new RequestError({
|
||||
code: 'user.identity_already_in_use',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedUser = await updateUserById(userId, {
|
||||
identities: {
|
||||
...user.identities,
|
||||
[target]: {
|
||||
userId: socialUserInfo.id,
|
||||
details: socialUserInfo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ctx.body = updatedUser.identities;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/users/:userId/identities/:target',
|
||||
koaGuard({
|
||||
params: object({ userId: string(), target: string() }),
|
||||
response: userProfileResponseGuard,
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { userId, target },
|
||||
} = ctx.guard;
|
||||
|
||||
const { identities } = await findUserById(userId);
|
||||
|
||||
if (!has(identities, target)) {
|
||||
throw new RequestError({ code: 'user.identity_not_exist', status: 404 });
|
||||
}
|
||||
|
||||
const updatedUser = await deleteUserIdentity(userId, target);
|
||||
ctx.body = pick(updatedUser, ...userInfoSelectFields);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -11,13 +11,6 @@ import { createRequester } from '#src/utils/test-utils.js';
|
|||
const { jest } = import.meta;
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const filterUsersWithSearch = (users: User[], search: string) =>
|
||||
users.filter((user) =>
|
||||
[user.username, user.primaryEmail, user.primaryPhone, user.name].some((value) =>
|
||||
value ? !value.includes(search) : false
|
||||
)
|
||||
);
|
||||
|
||||
const mockedQueries = {
|
||||
oidcModelInstances: { revokeInstanceByUserId: jest.fn() },
|
||||
signInExperiences: {
|
||||
|
@ -391,47 +384,4 @@ describe('adminUserRoutes', () => {
|
|||
500
|
||||
);
|
||||
});
|
||||
|
||||
it('DELETE /users/:userId/identities/:target should throw if user cannot be found', async () => {
|
||||
const notExistedUserId = 'notExistedUserId';
|
||||
const arbitraryTarget = 'arbitraryTarget';
|
||||
const mockedFindUserById = findUserById as jest.Mock;
|
||||
mockedFindUserById.mockImplementationOnce((userId) => {
|
||||
if (userId === notExistedUserId) {
|
||||
throw new Error(' ');
|
||||
}
|
||||
});
|
||||
await expect(
|
||||
userRequest.delete(`/users/${notExistedUserId}/identities/${arbitraryTarget}`)
|
||||
).resolves.toHaveProperty('status', 500);
|
||||
expect(deleteUserIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DELETE /users/:userId/identities/:target should throw if user is found but connector cannot be found', async () => {
|
||||
const arbitraryUserId = 'arbitraryUserId';
|
||||
const nonExistedTarget = 'nonExistedTarget';
|
||||
const mockedFindUserById = findUserById as jest.Mock;
|
||||
mockedFindUserById.mockImplementationOnce((userId) => {
|
||||
if (userId === arbitraryUserId) {
|
||||
return { identities: { connector1: {}, connector2: {} } };
|
||||
}
|
||||
});
|
||||
await expect(
|
||||
userRequest.delete(`/users/${arbitraryUserId}/identities/${nonExistedTarget}`)
|
||||
).resolves.toHaveProperty('status', 404);
|
||||
expect(deleteUserIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DELETE /users/:userId/identities/:target', async () => {
|
||||
const arbitraryUserId = 'arbitraryUserId';
|
||||
const arbitraryTarget = 'arbitraryTarget';
|
||||
const mockedFindUserById = findUserById as jest.Mock;
|
||||
mockedFindUserById.mockImplementationOnce((userId) => {
|
||||
if (userId === arbitraryUserId) {
|
||||
return { identities: { connectorTarget1: {}, connectorTarget2: {}, arbitraryTarget: {} } };
|
||||
}
|
||||
});
|
||||
await userRequest.delete(`/users/${arbitraryUserId}/identities/${arbitraryTarget}`);
|
||||
expect(deleteUserIdentity).toHaveBeenCalledWith(arbitraryUserId, arbitraryTarget);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import { jsonObjectGuard, userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas';
|
||||
import { conditional, has, pick } from '@silverhand/essentials';
|
||||
import { conditional, pick } from '@silverhand/essentials';
|
||||
import { boolean, literal, object, string } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
@ -17,7 +17,6 @@ export default function adminUserRoutes<T extends AuthedRouter>(
|
|||
oidcModelInstances: { revokeInstanceByUserId },
|
||||
users: {
|
||||
deleteUserById,
|
||||
deleteUserIdentity,
|
||||
findUserById,
|
||||
hasUser,
|
||||
updateUserById,
|
||||
|
@ -302,29 +301,4 @@ export default function adminUserRoutes<T extends AuthedRouter>(
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/users/:userId/identities/:target',
|
||||
koaGuard({
|
||||
params: object({ userId: string(), target: string() }),
|
||||
response: userProfileResponseGuard,
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { userId, target },
|
||||
} = ctx.guard;
|
||||
|
||||
const { identities } = await findUserById(userId);
|
||||
|
||||
if (!has(identities, target)) {
|
||||
throw new RequestError({ code: 'user.identity_not_exist', status: 404 });
|
||||
}
|
||||
|
||||
const updatedUser = await deleteUserIdentity(userId, target);
|
||||
ctx.body = pick(updatedUser, ...userInfoSelectFields);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
100
packages/core/src/routes/connector/authorization-uri.test.ts
Normal file
100
packages/core/src/routes/connector/authorization-uri.test.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { pickDefault } from '@logto/shared/esm';
|
||||
|
||||
import { mockLogtoConnectorList } from '#src/__mocks__/connector.js';
|
||||
import { mockConnector0, mockLogtoConnector, mockMetadata0 } from '#src/__mocks__/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { ConnectorType, type LogtoConnector } from '#src/utils/connectors/types.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const getLogtoConnectors = jest.fn<Promise<LogtoConnector[]>, []>();
|
||||
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{},
|
||||
{
|
||||
getLogtoConnectors,
|
||||
getLogtoConnectorById: async (connectorId: string) => {
|
||||
const connectors = await getLogtoConnectors();
|
||||
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
|
||||
assertThat(
|
||||
connector,
|
||||
new RequestError({
|
||||
code: 'entity.not_found',
|
||||
connectorId,
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
return connector;
|
||||
},
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const connectorAuthorizationUriRoutes = await pickDefault(import('./authorization-uri.js'));
|
||||
|
||||
describe('POST /connectors/:connectorId/authorization-uri', () => {
|
||||
const connectorRequest = createRequester({
|
||||
authedRoutes: connectorAuthorizationUriRoutes,
|
||||
tenantContext,
|
||||
});
|
||||
|
||||
it('should return 404 if connector not found', async () => {
|
||||
getLogtoConnectors.mockResolvedValueOnce([]);
|
||||
const response = await connectorRequest
|
||||
.post('/connectors/non-exist-connector-id/authorization-uri')
|
||||
.send({
|
||||
state: 'random_state',
|
||||
redirectUri: 'http://example.com/callback/random_string',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 400 if state is missing in payload', async () => {
|
||||
const response = await connectorRequest
|
||||
.post('/connectors/id0/authorization-uri')
|
||||
.send({ redirectUri: 'http://example.com/callback/random_string' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 if redirectUri is missing in payload', async () => {
|
||||
const response = await connectorRequest
|
||||
.post('/connectors/id0/authorization-uri')
|
||||
.send({ state: 'random_state' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 if connector type is not social', async () => {
|
||||
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList);
|
||||
const response = await connectorRequest.post('/connectors/id1/authorization-uri').send({
|
||||
state: 'random_state',
|
||||
redirectUri: 'http://example.com/callback/random_string',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return connector authorization URI successfully', async () => {
|
||||
getLogtoConnectors.mockResolvedValueOnce([
|
||||
{
|
||||
dbEntry: mockConnector0,
|
||||
metadata: { ...mockMetadata0 },
|
||||
type: ConnectorType.Social,
|
||||
...mockLogtoConnector,
|
||||
getAuthorizationUri: async () => 'http://example.com',
|
||||
},
|
||||
]);
|
||||
const response = await connectorRequest.post('/connectors/id0/authorization-uri').send({
|
||||
state: 'random_state',
|
||||
redirectUri: 'http://example.com/callback/random_string',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(response.body.redirectTo).toBe('http://example.com');
|
||||
});
|
||||
});
|
74
packages/core/src/routes/connector/authorization-uri.ts
Normal file
74
packages/core/src/routes/connector/authorization-uri.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { notImplemented } from '@logto/cli/lib/connector/consts.js';
|
||||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
|
||||
|
||||
export default function connectorAuthorizationUriRoutes<T extends AuthedRouter>(
|
||||
...[router, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const { getLogtoConnectorById } = tenant.connectors;
|
||||
|
||||
/**
|
||||
* Returns the authorization uri of the social platform that bundled with the given social connector.
|
||||
* With the returned authorization uri, you can redirect users to the social platform to authenticate.
|
||||
* The usage of this API is usually coupled with `POST /users/:userId/identities` to link authenticated
|
||||
* social identity to a Logto user.
|
||||
*
|
||||
* Note: Currently due to technical limitations, this API does not support the following connectors that
|
||||
* rely on Logto interaction session: `@logto/apple`, `@logto/connector-saml`, `@logto/connector-oidc`
|
||||
* and `@logto/connector-oauth`.
|
||||
*
|
||||
* @param {string} connectorId - The id of the connector
|
||||
* @param {string} state - A random string generated on the client side to prevent CSRF attack
|
||||
* @param {string} redirectUri - The uri to navigate back to after authenticated by the social platform
|
||||
*/
|
||||
router.post(
|
||||
'/connectors/:connectorId/authorization-uri',
|
||||
koaGuard({
|
||||
params: object({ connectorId: string().min(1) }),
|
||||
body: object({ state: string(), redirectUri: string() }),
|
||||
response: object({ redirectTo: string().url() }),
|
||||
status: [200, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { connectorId } = ctx.guard.params;
|
||||
const { state, redirectUri } = ctx.guard.body;
|
||||
assertThat(state && redirectUri, 'session.insufficient_info');
|
||||
|
||||
const connector = await getLogtoConnectorById(connectorId);
|
||||
assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type');
|
||||
|
||||
const {
|
||||
headers: { 'user-agent': userAgent },
|
||||
} = ctx.request;
|
||||
|
||||
const redirectTo = await connector.getAuthorizationUri(
|
||||
{
|
||||
state,
|
||||
redirectUri,
|
||||
connectorId,
|
||||
connectorFactoryId: connector.metadata.id,
|
||||
/**
|
||||
* Passing empty jti only works for connectors not relying on session storage.
|
||||
* E.g. Google and GitHub
|
||||
*/
|
||||
jti: '',
|
||||
headers: { userAgent },
|
||||
},
|
||||
/**
|
||||
* Same as above, passing `notImplemented` only works for connectors not relying on session storage.
|
||||
* E.g. Google and GitHub
|
||||
*/
|
||||
notImplemented
|
||||
);
|
||||
|
||||
ctx.body = { redirectTo };
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -22,6 +22,7 @@ import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/conn
|
|||
|
||||
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
import connectorAuthorizationUriRoutes from './authorization-uri.js';
|
||||
import connectorConfigTestingRoutes from './config-testing.js';
|
||||
|
||||
const generateConnectorId = buildIdGenerator(12);
|
||||
|
@ -341,4 +342,5 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
);
|
||||
|
||||
connectorConfigTestingRoutes(router, tenant);
|
||||
connectorAuthorizationUriRoutes(router, tenant);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import koaAuth from '../middleware/koa-auth/index.js';
|
|||
|
||||
import adminUserRoleRoutes from './admin-user-role.js';
|
||||
import adminUserSearchRoutes from './admin-user-search.js';
|
||||
import adminUserSocialRoutes from './admin-user-social.js';
|
||||
import adminUserRoutes from './admin-user.js';
|
||||
import applicationRoutes from './application.js';
|
||||
import authnRoutes from './authn.js';
|
||||
|
@ -46,6 +47,7 @@ const createRouters = (tenant: TenantContext) => {
|
|||
adminUserRoutes(managementRouter, tenant);
|
||||
adminUserSearchRoutes(managementRouter, tenant);
|
||||
adminUserRoleRoutes(managementRouter, tenant);
|
||||
adminUserSocialRoutes(managementRouter, tenant);
|
||||
logRoutes(managementRouter, tenant);
|
||||
roleRoutes(managementRouter, tenant);
|
||||
roleScopeRoutes(managementRouter, tenant);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Role, User } from '@logto/schemas';
|
||||
import type { Identities, Role, User } from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
||||
|
@ -51,3 +51,17 @@ export const getUserRoles = async (userId: string) =>
|
|||
|
||||
export const deleteRoleFromUser = async (userId: string, roleId: string) =>
|
||||
authedAdminApi.delete(`users/${userId}/roles/${roleId}`);
|
||||
|
||||
export const postUserIdentity = async (
|
||||
userId: string,
|
||||
connectorId: string,
|
||||
connectorData: Record<string, unknown>
|
||||
) =>
|
||||
authedAdminApi
|
||||
.post(`users/${userId}/identities`, {
|
||||
json: {
|
||||
connectorId,
|
||||
connectorData,
|
||||
},
|
||||
})
|
||||
.json<Identities>();
|
||||
|
|
|
@ -74,3 +74,15 @@ const sendTestMessage = async (
|
|||
url: `connectors/${connectorFactoryId}/test`,
|
||||
json: { [receiverType]: receiver, config },
|
||||
});
|
||||
|
||||
export const getConnectorAuthorizationUri = async (
|
||||
connectorId: string,
|
||||
state: string,
|
||||
redirectUri: string
|
||||
) =>
|
||||
authedAdminApi
|
||||
.post({
|
||||
url: `connectors/${connectorId}/authorization-uri`,
|
||||
json: { state, redirectUri },
|
||||
})
|
||||
.json<{ redirectTo: string }>();
|
||||
|
|
|
@ -12,7 +12,9 @@ import {
|
|||
updateUserPassword,
|
||||
deleteUserIdentity,
|
||||
postConnector,
|
||||
getConnectorAuthorizationUri,
|
||||
deleteConnectorById,
|
||||
postUserIdentity,
|
||||
} from '#src/api/index.js';
|
||||
import { createResponseWithCode } from '#src/helpers/admin-tenant.js';
|
||||
import { createUserByAdmin } from '#src/helpers/index.js';
|
||||
|
@ -93,13 +95,50 @@ describe('admin console user management', () => {
|
|||
expect(userEntity).toMatchObject(user);
|
||||
});
|
||||
|
||||
it('should delete user identities successfully', async () => {
|
||||
const { id } = await postConnector({
|
||||
it('should link social identity successfully', async () => {
|
||||
const { id: connectorId } = await postConnector({
|
||||
connectorId: mockSocialConnectorId,
|
||||
config: mockSocialConnectorConfig,
|
||||
});
|
||||
|
||||
const createdUserId = await createNewSocialUserWithUsernameAndPassword(id);
|
||||
const state = 'random_state';
|
||||
const redirectUri = 'http://mock.social.com/callback/random_string';
|
||||
const code = 'random_code_from_social';
|
||||
const socialUserId = 'social_platform_user_id';
|
||||
const socialUserEmail = 'johndoe@gmail.com';
|
||||
|
||||
const { id: userId } = await createUserByAdmin();
|
||||
const { redirectTo } = await getConnectorAuthorizationUri(connectorId, state, redirectUri);
|
||||
|
||||
expect(redirectTo).toBe(`http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`);
|
||||
|
||||
const identities = await postUserIdentity(userId, connectorId, {
|
||||
code,
|
||||
state,
|
||||
redirectUri,
|
||||
userId: socialUserId,
|
||||
email: socialUserEmail,
|
||||
});
|
||||
|
||||
expect(identities).toHaveProperty(mockSocialConnectorTarget);
|
||||
expect(identities[mockSocialConnectorTarget]).toMatchObject({
|
||||
userId: socialUserId,
|
||||
details: {
|
||||
id: socialUserId,
|
||||
email: socialUserEmail,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteConnectorById(connectorId);
|
||||
});
|
||||
|
||||
it('should delete user identities successfully', async () => {
|
||||
const { id: connectorId } = await postConnector({
|
||||
connectorId: mockSocialConnectorId,
|
||||
config: mockSocialConnectorConfig,
|
||||
});
|
||||
|
||||
const createdUserId = await createNewSocialUserWithUsernameAndPassword(connectorId);
|
||||
|
||||
const userInfo = await getUser(createdUserId);
|
||||
expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget);
|
||||
|
@ -110,6 +149,6 @@ describe('admin console user management', () => {
|
|||
|
||||
expect(updatedUser.identities).not.toHaveProperty(mockSocialConnectorTarget);
|
||||
|
||||
await deleteConnectorById(id);
|
||||
await deleteConnectorById(connectorId);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue