Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f458802

Browse files
committedJun 16, 2025··
feat(core,schemas): add user_social_identity_ids table
add user_social_identity_ids table
1 parent 47b2547 commit f458802

File tree

8 files changed

+397
-118
lines changed

8 files changed

+397
-118
lines changed
 
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type UserSocialIdentityId, UserSocialIdentityIds } from '@logto/schemas';
2+
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
3+
4+
import { convertToIdentifiers } from '../utils/sql.js';
5+
6+
const { table, fields } = convertToIdentifiers(UserSocialIdentityIds);
7+
8+
/**
9+
* The `UserSocialIdentityIds` table is automatically synchronized with the `Users` table
10+
* through a database trigger. This table serves as a read-only view of user social identity IDs.
11+
*
12+
* Important: This table should only be queried for reading data. Any write operations
13+
* should be performed on the `Users` table instead, as the trigger will handle the synchronization.
14+
*/
15+
export const createUserSocialIdentityIdsQueries = (pool: CommonQueryMethods) => {
16+
const findUserSocialIdentityIdsByUserId = async (userId: string) => {
17+
return pool.any<UserSocialIdentityId>(sql`
18+
select ${sql.join(Object.values(fields), sql`, `)}
19+
from ${table}
20+
where ${fields.userId}=${userId}
21+
`);
22+
};
23+
24+
return {
25+
findUserSocialIdentityIdsByUserId,
26+
};
27+
};

‎packages/core/src/routes/admin-user/social.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
identityGuard,
55
identitiesGuard,
66
userProfileResponseGuard,
7+
UserSocialIdentityIds,
78
} from '@logto/schemas';
89
import { has } from '@silverhand/essentials';
910
import { object, record, string, unknown } from 'zod';
@@ -12,6 +13,7 @@ import RequestError from '#src/errors/RequestError/index.js';
1213
import koaGuard from '#src/middleware/koa-guard.js';
1314
import assertThat from '#src/utils/assert-that.js';
1415

16+
import { EnvSet } from '../../env-set/index.js';
1517
import { transpileUserProfileResponse } from '../../utils/user.js';
1618
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
1719

@@ -21,6 +23,7 @@ export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
2123
const {
2224
queries: {
2325
users: { findUserById, updateUserById, hasUserWithIdentity, deleteUserIdentity },
26+
userSocialIdentityIds: { findUserSocialIdentityIdsByUserId },
2427
},
2528
connectors: { getLogtoConnectorById },
2629
} = tenant;
@@ -154,4 +157,27 @@ export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
154157
return next();
155158
}
156159
);
160+
161+
// For intergration test use only
162+
if (EnvSet.values.isIntegrationTest) {
163+
router.get(
164+
'/users/:userId/identity-ids',
165+
koaGuard({
166+
params: object({ userId: string() }),
167+
response: UserSocialIdentityIds.guard.array(),
168+
status: [200],
169+
}),
170+
async (ctx, next) => {
171+
const {
172+
params: { userId },
173+
} = ctx.guard;
174+
175+
const identityIds = await findUserSocialIdentityIdsByUserId(userId);
176+
177+
ctx.body = identityIds;
178+
179+
return next();
180+
}
181+
);
182+
}
157183
}

‎packages/core/src/tenants/Queries.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { OidcSessionExtensionsQueries } from '../queries/oidc-session-extensions
4141
import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js';
4242
import { createSentinelActivitiesQueries } from '../queries/sentinel-activities.js';
4343
import TenantUsageQuery from '../queries/tenant-usage/index.js';
44+
import { createUserSocialIdentityIdsQueries } from '../queries/user-social-identity-ids.js';
4445
import { VerificationRecordQueries } from '../queries/verification-records.js';
4546

4647
export default class Queries {
@@ -84,6 +85,7 @@ export default class Queries {
8485
captchaProviders = new CaptchaProviderQueries(this.pool);
8586
sentinelActivities = createSentinelActivitiesQueries(this.pool);
8687
oidcSessionExtensions = new OidcSessionExtensionsQueries(this.pool);
88+
userSocialIdentityIds = createUserSocialIdentityIdsQueries(this.pool);
8789

8890
constructor(
8991
public readonly pool: CommonQueryMethods,

‎packages/integration-tests/src/api/admin-user.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
PersonalAccessToken,
99
Role,
1010
User,
11+
UserSocialIdentityId,
1112
UserSsoIdentity,
1213
UsersPasswordEncryptionMethod,
1314
} from '@logto/schemas';
@@ -157,3 +158,7 @@ export const updatePersonalAccessToken = async (
157158
json: body,
158159
})
159160
.json<PersonalAccessToken>();
161+
162+
export const getUserIdentityIds = async (userId: string) => {
163+
return authedAdminApi.get(`users/${userId}/identity-ids`).json<UserSocialIdentityId[]>();
164+
};
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { ConnectorType } from '@logto/connector-kit';
2+
3+
import {
4+
mockSocialConnectorId,
5+
mockSocialConnectorConfig,
6+
mockSocialConnectorTarget,
7+
} from '#src/__mocks__/connectors-mock.js';
8+
import {
9+
postUserIdentity,
10+
putUserIdentity,
11+
getUser,
12+
deleteUserIdentity,
13+
getUserIdentityIds,
14+
} from '#src/api/admin-user.js';
15+
import {
16+
postConnector,
17+
getConnectorAuthorizationUri,
18+
deleteConnectorById,
19+
} from '#src/api/connector.js';
20+
import { clearConnectorsByTypes } from '#src/helpers/connector.js';
21+
import { createUserByAdmin } from '#src/helpers/index.js';
22+
import { createNewSocialUserWithUsernameAndPassword } from '#src/helpers/interactions.js';
23+
import { randomString } from '#src/utils.js';
24+
25+
describe('admin user identities management API', () => {
26+
beforeAll(async () => {
27+
await clearConnectorsByTypes([ConnectorType.Social]);
28+
});
29+
30+
it('should link social identity successfully', async () => {
31+
const { id: connectorId } = await postConnector({
32+
connectorId: mockSocialConnectorId,
33+
config: mockSocialConnectorConfig,
34+
});
35+
36+
const state = 'random_state';
37+
const redirectUri = 'http://mock-social/callback/random_string';
38+
const code = 'random_code_from_social';
39+
const socialUserId = 'social_platform_user_id_' + randomString();
40+
const socialUserEmail = `johndoe_${randomString()}@gmail.com`;
41+
const anotherSocialUserId = 'another_social_platform_user_id_' + randomString();
42+
const socialTarget = 'social_target';
43+
const socialIdentity = {
44+
userId: 'social_identity_user_id_' + randomString(),
45+
details: {
46+
age: 21,
47+
email: 'foo@logto.io',
48+
},
49+
};
50+
51+
const { id: userId } = await createUserByAdmin();
52+
const { redirectTo } = await getConnectorAuthorizationUri(connectorId, state, redirectUri);
53+
54+
expect(redirectTo).toBe(`http://mock-social/?state=${state}&redirect_uri=${redirectUri}`);
55+
56+
const identities = await postUserIdentity(userId, connectorId, {
57+
code,
58+
state,
59+
redirectUri,
60+
userId: socialUserId,
61+
email: socialUserEmail,
62+
});
63+
64+
expect(identities).toHaveProperty(mockSocialConnectorTarget);
65+
expect(identities[mockSocialConnectorTarget]).toMatchObject({
66+
userId: socialUserId,
67+
details: {
68+
id: socialUserId,
69+
email: socialUserEmail,
70+
rawData: {
71+
code,
72+
email: socialUserEmail,
73+
redirectUri,
74+
state,
75+
userId: socialUserId,
76+
},
77+
},
78+
});
79+
80+
// Check user identity ids are synced
81+
const userIdentityIds = await getUserIdentityIds(userId);
82+
expect(userIdentityIds).toHaveLength(1);
83+
expect(userIdentityIds[0]).toMatchObject({
84+
userId,
85+
target: mockSocialConnectorTarget,
86+
identityId: socialUserId,
87+
});
88+
89+
const updatedIdentity = await putUserIdentity(userId, mockSocialConnectorTarget, {
90+
userId: anotherSocialUserId,
91+
details: {
92+
id: anotherSocialUserId,
93+
rawData: {
94+
userId: anotherSocialUserId,
95+
},
96+
},
97+
});
98+
99+
expect(updatedIdentity).toHaveProperty(mockSocialConnectorTarget);
100+
expect(updatedIdentity[mockSocialConnectorTarget]).toMatchObject({
101+
userId: anotherSocialUserId,
102+
details: {
103+
id: anotherSocialUserId,
104+
rawData: {
105+
userId: anotherSocialUserId,
106+
},
107+
},
108+
});
109+
110+
// Check user identity ids are updated
111+
const updatedUserIdentityIds = await getUserIdentityIds(userId);
112+
expect(updatedUserIdentityIds).toHaveLength(1);
113+
expect(updatedUserIdentityIds[0]).toMatchObject({
114+
userId,
115+
target: mockSocialConnectorTarget,
116+
identityId: anotherSocialUserId,
117+
});
118+
119+
const updatedIdentities = await putUserIdentity(userId, socialTarget, socialIdentity);
120+
expect(updatedIdentities).toMatchObject({
121+
[mockSocialConnectorTarget]: {
122+
userId: anotherSocialUserId,
123+
},
124+
[socialTarget]: socialIdentity,
125+
});
126+
127+
// Check new user identity id
128+
const newUserIdentityIds = await getUserIdentityIds(userId);
129+
expect(newUserIdentityIds).toHaveLength(2);
130+
expect(
131+
newUserIdentityIds.find(
132+
({ target, identityId }) => target === socialTarget && identityId === socialIdentity.userId
133+
)
134+
).toBeTruthy();
135+
136+
await deleteConnectorById(connectorId);
137+
});
138+
139+
it('should delete user identities successfully', async () => {
140+
const { id: connectorId } = await postConnector({
141+
connectorId: mockSocialConnectorId,
142+
config: mockSocialConnectorConfig,
143+
});
144+
145+
const createdUserId = await createNewSocialUserWithUsernameAndPassword(connectorId);
146+
147+
const userInfo = await getUser(createdUserId);
148+
expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget);
149+
150+
const useIdentityIds = await getUserIdentityIds(createdUserId);
151+
expect(useIdentityIds).toHaveLength(1);
152+
expect(useIdentityIds[0]).toMatchObject({
153+
userId: createdUserId,
154+
target: mockSocialConnectorTarget,
155+
identityId: userInfo.identities[mockSocialConnectorTarget]?.userId,
156+
});
157+
158+
await deleteUserIdentity(createdUserId, mockSocialConnectorTarget);
159+
160+
const updatedUser = await getUser(createdUserId);
161+
expect(updatedUser.identities).not.toHaveProperty(mockSocialConnectorTarget);
162+
163+
const updatedIdentityIds = await getUserIdentityIds(createdUserId);
164+
expect(updatedIdentityIds).toHaveLength(0);
165+
166+
await deleteConnectorById(connectorId);
167+
});
168+
});

‎packages/integration-tests/src/tests/api/admin-user.test.ts

Lines changed: 1 addition & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,23 @@
11
import { UsersPasswordEncryptionMethod, ConnectorType } from '@logto/schemas';
22
import { HTTPError } from 'ky';
33

4-
import {
5-
mockSocialConnectorConfig,
6-
mockSocialConnectorId,
7-
mockSocialConnectorTarget,
8-
} from '#src/__mocks__/connectors-mock.js';
94
import {
105
getUser,
116
updateUser,
127
deleteUser,
138
updateUserPassword,
14-
deleteUserIdentity,
15-
postConnector,
16-
getConnectorAuthorizationUri,
17-
deleteConnectorById,
18-
postUserIdentity,
199
verifyUserPassword,
20-
putUserIdentity,
2110
updateUserProfile,
2211
} from '#src/api/index.js';
2312
import { clearConnectorsByTypes } from '#src/helpers/connector.js';
2413
import { createUserByAdmin, expectRejects } from '#src/helpers/index.js';
25-
import {
26-
createNewSocialUserWithUsernameAndPassword,
27-
signInWithPassword,
28-
} from '#src/helpers/interactions.js';
14+
import { signInWithPassword } from '#src/helpers/interactions.js';
2915
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
3016
import {
3117
generateUsername,
3218
generateEmail,
3319
generatePhone,
3420
generatePassword,
35-
randomString,
3621
generateNationalPhoneNumber,
3722
} from '#src/utils.js';
3823

@@ -210,108 +195,6 @@ describe('admin console user management', () => {
210195
expect(userEntity.updatedAt).toBeGreaterThan(updatedAt);
211196
});
212197

213-
it('should link social identity successfully', async () => {
214-
const { id: connectorId } = await postConnector({
215-
connectorId: mockSocialConnectorId,
216-
config: mockSocialConnectorConfig,
217-
});
218-
219-
const state = 'random_state';
220-
const redirectUri = 'http://mock-social/callback/random_string';
221-
const code = 'random_code_from_social';
222-
const socialUserId = 'social_platform_user_id_' + randomString();
223-
const socialUserEmail = `johndoe_${randomString()}@gmail.com`;
224-
const anotherSocialUserId = 'another_social_platform_user_id_' + randomString();
225-
const socialTarget = 'social_target';
226-
const socialIdentity = {
227-
userId: 'social_identity_user_id_' + randomString(),
228-
details: {
229-
age: 21,
230-
email: 'foo@logto.io',
231-
},
232-
};
233-
234-
const { id: userId } = await createUserByAdmin();
235-
const { redirectTo } = await getConnectorAuthorizationUri(connectorId, state, redirectUri);
236-
237-
expect(redirectTo).toBe(`http://mock-social/?state=${state}&redirect_uri=${redirectUri}`);
238-
239-
const identities = await postUserIdentity(userId, connectorId, {
240-
code,
241-
state,
242-
redirectUri,
243-
userId: socialUserId,
244-
email: socialUserEmail,
245-
});
246-
247-
expect(identities).toHaveProperty(mockSocialConnectorTarget);
248-
expect(identities[mockSocialConnectorTarget]).toMatchObject({
249-
userId: socialUserId,
250-
details: {
251-
id: socialUserId,
252-
email: socialUserEmail,
253-
rawData: {
254-
code,
255-
email: socialUserEmail,
256-
redirectUri,
257-
state,
258-
userId: socialUserId,
259-
},
260-
},
261-
});
262-
263-
const updatedIdentity = await putUserIdentity(userId, mockSocialConnectorTarget, {
264-
userId: anotherSocialUserId,
265-
details: {
266-
id: anotherSocialUserId,
267-
rawData: {
268-
userId: anotherSocialUserId,
269-
},
270-
},
271-
});
272-
273-
expect(updatedIdentity).toHaveProperty(mockSocialConnectorTarget);
274-
expect(updatedIdentity[mockSocialConnectorTarget]).toMatchObject({
275-
userId: anotherSocialUserId,
276-
details: {
277-
id: anotherSocialUserId,
278-
rawData: {
279-
userId: anotherSocialUserId,
280-
},
281-
},
282-
});
283-
284-
const updatedIdentities = await putUserIdentity(userId, socialTarget, socialIdentity);
285-
expect(updatedIdentities).toMatchObject({
286-
[mockSocialConnectorTarget]: {
287-
userId: anotherSocialUserId,
288-
},
289-
[socialTarget]: socialIdentity,
290-
});
291-
292-
await deleteConnectorById(connectorId);
293-
});
294-
295-
it('should delete user identities successfully', async () => {
296-
const { id: connectorId } = await postConnector({
297-
connectorId: mockSocialConnectorId,
298-
config: mockSocialConnectorConfig,
299-
});
300-
301-
const createdUserId = await createNewSocialUserWithUsernameAndPassword(connectorId);
302-
303-
const userInfo = await getUser(createdUserId);
304-
expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget);
305-
306-
await deleteUserIdentity(createdUserId, mockSocialConnectorTarget);
307-
308-
const updatedUser = await getUser(createdUserId);
309-
310-
expect(updatedUser.identities).not.toHaveProperty(mockSocialConnectorTarget);
311-
312-
await deleteConnectorById(connectorId);
313-
});
314-
315198
it('should return 204 if password is correct', async () => {
316199
const user = await createUserByAdmin({ password: 'new_password' });
317200
expect(await verifyUserPassword(user.id, 'new_password')).toHaveProperty('status', 204);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { sql } from '@silverhand/slonik';
2+
3+
import type { AlterationScript } from '../lib/types/alteration.js';
4+
5+
import { applyTableRls, dropTableRls } from './utils/1704934999-tables.js';
6+
7+
const alteration: AlterationScript = {
8+
up: async (pool) => {
9+
await pool.query(sql`
10+
create table if not exists user_social_identity_ids (
11+
tenant_id varchar(21) not null
12+
references tenants(id) on update cascade on delete cascade,
13+
user_id varchar(12) not null
14+
references users (id) on update cascade on delete cascade,
15+
target varchar(256) not null,
16+
identity_id varchar(128) not null,
17+
created_at timestamptz not null default (now()),
18+
primary key (tenant_id, user_id, target),
19+
constraint user_social_identity_ids__tenant_id__target__identity_id
20+
unique (tenant_id, target, identity_id)
21+
);
22+
`);
23+
24+
await applyTableRls(pool, 'user_social_identity_ids');
25+
26+
await pool.query(sql`
27+
create function sync_user_social_identity_ids()
28+
returns trigger as $$
29+
declare
30+
target_key text;
31+
identity_value jsonb;
32+
existing_targets text[];
33+
begin
34+
-- skip sync if this is an update and identities haven't changed
35+
if tg_op = 'UPDATE' and new.identities is not distinct from old.identities then
36+
return new;
37+
end if;
38+
39+
-- extract all target keys from the new identities jsonb
40+
select array_agg(key) into existing_targets
41+
from jsonb_object_keys(new.identities) as key;
42+
43+
-- delete entries for this user that no longer exist in the new identities
44+
delete from user_social_identity_ids
45+
where user_id = new.id
46+
and tenant_id = new.tenant_id
47+
and target not in (select * from unnest(existing_targets));
48+
49+
-- upsert each identity entry
50+
for target_key, identity_value in
51+
select * from jsonb_each(new.identities)
52+
loop
53+
insert into user_social_identity_ids (
54+
tenant_id,
55+
user_id,
56+
target,
57+
identity_id
58+
)
59+
values (
60+
new.tenant_id,
61+
new.id,
62+
target_key,
63+
identity_value ->> 'userId'
64+
)
65+
-- update the identity_id if target already exists
66+
on conflict (tenant_id, user_id, target)
67+
do update set identity_id = excluded.identity_id;
68+
end loop;
69+
70+
return new;
71+
end;
72+
$$ language plpgsql;
73+
`);
74+
75+
await pool.query(sql`
76+
create trigger sync_user_social_identity_ids_trigger
77+
after insert or update of identities on users
78+
for each row
79+
execute procedure sync_user_social_identity_ids();
80+
`);
81+
},
82+
down: async (pool) => {
83+
await pool.query(sql`
84+
drop trigger if exists sync_user_social_identity_ids_trigger on users;
85+
`);
86+
87+
await pool.query(sql`
88+
drop function if exists sync_user_social_identity_ids() cascade;
89+
`);
90+
91+
await dropTableRls(pool, 'user_social_identity_ids');
92+
93+
await pool.query(sql`
94+
drop table if exists user_social_identity_ids;
95+
`);
96+
},
97+
};
98+
99+
export default alteration;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/* init_order = 2 */
2+
3+
create table user_social_identity_ids (
4+
tenant_id varchar(21) not null
5+
references tenants (id) on update cascade on delete cascade,
6+
user_id varchar(12) not null
7+
references users (id) on update cascade on delete cascade,
8+
/** Unique social provider identifier. E.g., 'google', 'facebook', etc. */
9+
target varchar(256) not null,
10+
/** Unique identifier for the user in the social provider. */
11+
identity_id varchar(128) not null,
12+
created_at timestamptz not null default (now()),
13+
primary key (tenant_id, user_id, target),
14+
constraint user_social_identity_ids__tenant_id__target__identity_id
15+
unique (tenant_id, target, identity_id)
16+
);
17+
18+
create function sync_user_social_identity_ids()
19+
returns trigger as $$
20+
declare
21+
target_key text;
22+
identity_value jsonb;
23+
existing_targets text[];
24+
begin
25+
-- skip sync if this is an update and identities haven't changed
26+
if tg_op = 'UPDATE' and new.identities is not distinct from old.identities then
27+
return new;
28+
end if;
29+
30+
-- extract all target keys from the new identities jsonb
31+
select array_agg(key) into existing_targets
32+
from jsonb_object_keys(new.identities) as key;
33+
34+
-- delete entries for this user that no longer exist in the new identities
35+
delete from user_social_identity_ids
36+
where user_id = new.id
37+
and tenant_id = new.tenant_id
38+
and target not in (select * from unnest(existing_targets));
39+
40+
-- upsert each identity entry
41+
for target_key, identity_value in
42+
select * from jsonb_each(new.identities)
43+
loop
44+
insert into user_social_identity_ids (
45+
tenant_id,
46+
user_id,
47+
target,
48+
identity_id
49+
)
50+
values (
51+
new.tenant_id,
52+
new.id,
53+
target_key,
54+
identity_value ->> 'userId'
55+
)
56+
-- update the identity_id if target already exists
57+
on conflict (tenant_id, user_id, target)
58+
do update set identity_id = excluded.identity_id;
59+
end loop;
60+
61+
return new;
62+
end;
63+
$$ language plpgsql;
64+
65+
66+
create trigger sync_user_social_identity_ids_trigger
67+
after insert or update of identities on users
68+
for each row
69+
execute function sync_user_social_identity_ids();

0 commit comments

Comments
 (0)
Please sign in to comment.