Skip to content

Commit c4d7590

Browse files
committed
feat(core): list user grants by userId
list user grants by userId
1 parent 74c993a commit c4d7590

11 files changed

Lines changed: 370 additions & 6 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
type OidcGrantInstance = {
2+
id: string;
3+
payload: {
4+
kind: 'Grant';
5+
clientId: string;
6+
accountId: string;
7+
};
8+
expiresAt: number;
9+
};
10+
11+
export const createMockOidcGrantInstance = (
12+
overrides?: Partial<OidcGrantInstance>
13+
): OidcGrantInstance => ({
14+
id: 'grant-id',
15+
payload: {
16+
kind: 'Grant',
17+
clientId: 'demo-app',
18+
accountId: 'user-id',
19+
},
20+
expiresAt: 1_787_997_038_000,
21+
...overrides,
22+
});

packages/core/src/libraries/session.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
jsonObjectGuard,
44
jwtCustomizerUserInteractionContextGuard,
55
oidcSessionInstancePayloadGuard,
6+
userApplicationGrantPayloadGuard,
67
} from '@logto/schemas';
78
import { conditional, deduplicate } from '@silverhand/essentials';
89
import type { Context } from 'koa';
@@ -207,6 +208,29 @@ const formatSessionWithExtension = (session: SessionInstanceWithExtension) => {
207208
};
208209
};
209210

211+
const formatApplicationGrant = (
212+
grant: Awaited<
213+
ReturnType<Queries['oidcModelInstances']['findUserActiveApplicationGrants']>
214+
>[number]
215+
) => {
216+
const payloadResult = userApplicationGrantPayloadGuard.safeParse(grant.payload);
217+
218+
if (!payloadResult.success) {
219+
throw new RequestError(
220+
{ code: 'oidc.invalid_grant', status: 500 },
221+
{
222+
cause: payloadResult.error,
223+
}
224+
);
225+
}
226+
227+
return {
228+
id: grant.id,
229+
payload: payloadResult.data,
230+
expiresAt: grant.expiresAt,
231+
};
232+
};
233+
210234
/* ================ Session management =============== */
211235

212236
type SessionAuthorizationDetails = {
@@ -242,6 +266,18 @@ export const createSessionLibrary = (queries: Queries) => {
242266
return formatSessionWithExtension(result);
243267
};
244268

269+
const findUserActiveApplicationGrants = async (
270+
userId: string,
271+
applicationType?: 'thirdParty' | 'firstParty'
272+
) => {
273+
const result = await queries.oidcModelInstances.findUserActiveApplicationGrants(
274+
userId,
275+
applicationType
276+
);
277+
278+
return result.map((grant) => formatApplicationGrant(grant));
279+
};
280+
245281
const revokeSessionAssociatedGrants = async ({
246282
provider,
247283
authorizations,
@@ -295,6 +331,7 @@ export const createSessionLibrary = (queries: Queries) => {
295331
return {
296332
findUserActiveSessionsWithExtensions,
297333
findUserActiveSessionWithExtension,
334+
findUserActiveApplicationGrants,
298335
revokeSessionAssociatedGrants,
299336
};
300337
};

packages/core/src/queries/oidc-model-instance.test.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { CreateOidcModelInstance } from '@logto/schemas';
2-
import { OidcModelInstances } from '@logto/schemas';
2+
import { Applications, OidcModelInstances } from '@logto/schemas';
33
import { createMockPool, createMockQueryResult, sql } from '@silverhand/slonik';
44

5+
import { createMockOidcGrantInstance } from '#src/__mocks__/oidc-grant.js';
56
import { convertToIdentifiers } from '#src/utils/sql.js';
67
import type { QueryType } from '#src/utils/test-utils.js';
78
import { expectSqlAssert } from '#src/utils/test-utils.js';
@@ -24,10 +25,12 @@ const {
2425
consumeInstanceById,
2526
destroyInstanceById,
2627
revokeInstanceByGrantId,
28+
findUserActiveApplicationGrants,
2729
} = createOidcModelInstanceQueries(pool);
2830

2931
describe('oidc-model-instance query', () => {
3032
const { table, fields } = convertToIdentifiers(OidcModelInstances);
33+
const { table: applicationTable, fields: applicationFields } = convertToIdentifiers(Applications);
3134
const expiresAt = Date.now();
3235
const instance: CreateOidcModelInstance = {
3336
modelName: 'access_token',
@@ -71,7 +74,7 @@ describe('oidc-model-instance query', () => {
7174
const expectSql = sql`
7275
select ${fields.payload}, ${fields.consumedAt}
7376
from ${table}
74-
where "model_name"=$1
77+
where "model_name"=$1
7578
and "id"=$2
7679
`;
7780

@@ -92,7 +95,7 @@ describe('oidc-model-instance query', () => {
9295
const uid_value = 'foo';
9396

9497
// Mock a single result
95-
mockQuery.mockImplementationOnce(async (sql, values) => {
98+
mockQuery.mockImplementationOnce(async () => {
9699
// Simulate pool.any
97100
return createMockQueryResult([{ consumedAt: 10 }]);
98101
});
@@ -200,4 +203,76 @@ describe('oidc-model-instance query', () => {
200203

201204
await revokeInstanceByGrantId(instance.modelName, grantId);
202205
});
206+
207+
it('findUserActiveApplicationGrants with thirdparty', async () => {
208+
const userId = 'user-id';
209+
const expectSql = sql`
210+
select "oidc_model_instance"."id", "oidc_model_instance"."payload", "oidc_model_instance"."expires_at"
211+
from ${table} as "oidc_model_instance"
212+
inner join ${applicationTable} as "application"
213+
on "oidc_model_instance"."payload"->>'clientId'="application"."id"
214+
where "oidc_model_instance"."model_name"='Grant'
215+
and "oidc_model_instance"."payload"->>'accountId'=${userId}
216+
and "application"."is_third_party"=${true}
217+
and "oidc_model_instance"."expires_at" > to_timestamp($3)
218+
`;
219+
220+
const grant = createMockOidcGrantInstance({
221+
payload: { kind: 'Grant', clientId: 'demo-app', accountId: userId },
222+
});
223+
224+
mockQuery.mockImplementationOnce(async (sql, values) => {
225+
expectSqlAssert(sql, expectSql.sql);
226+
expect(values).toEqual([userId, true, expect.any(Number)]);
227+
228+
return createMockQueryResult([grant as never]);
229+
});
230+
231+
await expect(findUserActiveApplicationGrants(userId, 'thirdParty')).resolves.toEqual([grant]);
232+
});
233+
234+
it('findUserActiveApplicationGrants with firstparty', async () => {
235+
const userId = 'user-id';
236+
const expectSql = sql`
237+
select "oidc_model_instance"."id", "oidc_model_instance"."payload", "oidc_model_instance"."expires_at"
238+
from ${table} as "oidc_model_instance"
239+
inner join ${applicationTable} as "application"
240+
on "oidc_model_instance"."payload"->>'clientId'="application"."id"
241+
where "oidc_model_instance"."model_name"='Grant'
242+
and "oidc_model_instance"."payload"->>'accountId'=${userId}
243+
and "application"."is_third_party"=${false}
244+
and "oidc_model_instance"."expires_at" > to_timestamp($3)
245+
`;
246+
247+
mockQuery.mockImplementationOnce(async (sql, values) => {
248+
expectSqlAssert(sql, expectSql.sql);
249+
expect(values).toEqual([userId, false, expect.any(Number)]);
250+
251+
return createMockQueryResult([]);
252+
});
253+
254+
await expect(findUserActiveApplicationGrants(userId, 'firstParty')).resolves.toEqual([]);
255+
});
256+
257+
it('findUserActiveApplicationGrants with all', async () => {
258+
const userId = 'user-id';
259+
const expectSql = sql`
260+
select "oidc_model_instance"."id", "oidc_model_instance"."payload", "oidc_model_instance"."expires_at"
261+
from ${table} as "oidc_model_instance"
262+
inner join ${applicationTable} as "application"
263+
on "oidc_model_instance"."payload"->>'clientId'="application"."id"
264+
where "oidc_model_instance"."model_name"='Grant'
265+
and "oidc_model_instance"."payload"->>'accountId'=${userId}
266+
and "oidc_model_instance"."expires_at" > to_timestamp($2)
267+
`;
268+
269+
mockQuery.mockImplementationOnce(async (sql, values) => {
270+
expectSqlAssert(sql, expectSql.sql);
271+
expect(values).toEqual([userId, expect.any(Number)]);
272+
273+
return createMockQueryResult([]);
274+
});
275+
276+
await expect(findUserActiveApplicationGrants(userId)).resolves.toEqual([]);
277+
});
203278
});

packages/core/src/queries/oidc-model-instance.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { OidcModelInstance, OidcModelInstancePayload } from '@logto/schemas';
2-
import { OidcModelInstances } from '@logto/schemas';
2+
import { Applications, OidcModelInstances } from '@logto/schemas';
33
import type { Nullable } from '@silverhand/essentials';
44
import { conditional } from '@silverhand/essentials';
55
import type { CommonQueryMethods, ValueExpression } from '@silverhand/slonik';
@@ -13,6 +13,13 @@ export type WithConsumed<T> = T & { consumed?: boolean };
1313
export type QueryResult = Pick<OidcModelInstance, 'payload' | 'consumedAt'>;
1414

1515
const { table, fields } = convertToIdentifiers(OidcModelInstances);
16+
const { table: applicationTable, fields: applicationFields } = convertToIdentifiers(Applications);
17+
18+
export type ActiveApplicationGrantInstance = Pick<
19+
OidcModelInstance,
20+
'id' | 'payload' | 'expiresAt'
21+
> & { modelName: 'Grant' };
22+
export type GrantApplicationType = 'thirdParty' | 'firstParty';
1623

1724
/**
1825
* This interval helps to avoid concurrency issues when exchanging the rotating refresh token multiple times within a given timeframe;
@@ -136,6 +143,52 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => {
136143
`);
137144
};
138145

146+
const findUserActiveApplicationGrants = async (
147+
userId: string,
148+
applicationType?: GrantApplicationType
149+
) => {
150+
const oidcModelInstanceAlias = 'oidc_model_instance';
151+
const applicationAlias = 'application';
152+
const oidcModelInstanceTableIdentifier = sql.identifier([oidcModelInstanceAlias]);
153+
const applicationTableIdentifier = sql.identifier([applicationAlias]);
154+
const oidcModelInstanceId = sql.identifier([
155+
oidcModelInstanceAlias,
156+
OidcModelInstances.fields.id,
157+
]);
158+
const oidcModelInstancePayload = sql.identifier([
159+
oidcModelInstanceAlias,
160+
OidcModelInstances.fields.payload,
161+
]);
162+
const oidcModelInstanceExpiresAt = sql.identifier([
163+
oidcModelInstanceAlias,
164+
OidcModelInstances.fields.expiresAt,
165+
]);
166+
const oidcModelInstanceModelName = sql.identifier([
167+
oidcModelInstanceAlias,
168+
OidcModelInstances.fields.modelName,
169+
]);
170+
const applicationId = sql.identifier([applicationAlias, Applications.fields.id]);
171+
const applicationIsThirdParty = sql.identifier([
172+
applicationAlias,
173+
Applications.fields.isThirdParty,
174+
]);
175+
176+
return pool.any<ActiveApplicationGrantInstance>(sql`
177+
select ${oidcModelInstanceId}, ${oidcModelInstancePayload}, ${oidcModelInstanceExpiresAt}
178+
from ${table} as ${oidcModelInstanceTableIdentifier}
179+
inner join ${applicationTable} as ${applicationTableIdentifier}
180+
on ${oidcModelInstancePayload}->>'clientId'=${applicationId}
181+
where ${oidcModelInstanceModelName}='Grant'
182+
and ${oidcModelInstancePayload}->>'accountId'=${userId}
183+
${
184+
applicationType
185+
? sql`and ${applicationIsThirdParty}=${applicationType === 'thirdParty'}`
186+
: sql``
187+
}
188+
and ${oidcModelInstanceExpiresAt} > ${convertToTimestamp()}
189+
`);
190+
};
191+
139192
return {
140193
upsertInstance,
141194
findPayloadById,
@@ -144,5 +197,6 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => {
144197
destroyInstanceById,
145198
revokeInstanceByGrantId,
146199
revokeInstanceByUserId,
200+
findUserActiveApplicationGrants,
147201
};
148202
};

packages/core/src/routes/admin-user/session.openapi.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@
1212
"description": "Retrieve all non-expired sessions for the user, including session metadata and interaction details when available."
1313
}
1414
},
15+
"/api/users/{userId}/grants": {
16+
"get": {
17+
"tags": ["Dev feature"],
18+
"parameters": [
19+
{
20+
"in": "query",
21+
"name": "appType",
22+
"description": "Application type filter. Use 'thirdParty' to list third-party app grants only, or 'firstParty' to list first-party app grants only. If omitted, grants from all applications are returned."
23+
}
24+
],
25+
"responses": {
26+
"200": {
27+
"description": "Return non-expired grants of the user. Results are filtered by app type when `appType` is provided."
28+
}
29+
},
30+
"summary": "Get user active grants",
31+
"description": "Retrieve all non-expired grants of the user. Optionally filter by application type via `appType`; when omitted, grants from all application types are returned."
32+
}
33+
},
1534
"/api/users/{userId}/sessions/{sessionId}": {
1635
"get": {
1736
"parameters": [],

packages/core/src/routes/admin-user/session.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {
22
SessionGrantRevokeTarget,
3+
getUserApplicationGrantsResponseGuard,
34
getUserSessionResponseGuard,
45
getUserSessionsResponseGuard,
56
} from '@logto/schemas';
67
import { assert } from '@silverhand/essentials';
7-
import { nativeEnum, object, string } from 'zod';
8+
import { nativeEnum, object, string, enum as zodEnum } from 'zod';
89

910
import RequestError from '#src/errors/RequestError/index.js';
1011
import koaGuard from '#src/middleware/koa-guard.js';
@@ -41,6 +42,32 @@ export default function adminUserSessionRoutes<T extends ManagementApiRouter>(
4142
}
4243
);
4344

45+
router.get(
46+
'/users/:userId/grants',
47+
koaGuard({
48+
params: object({ userId: string() }),
49+
query: object({
50+
appType: zodEnum(['firstParty', 'thirdParty']).optional(),
51+
}),
52+
response: getUserApplicationGrantsResponseGuard,
53+
status: [200, 500],
54+
}),
55+
async (ctx, next) => {
56+
const {
57+
params: { userId },
58+
} = ctx.guard;
59+
60+
const { appType } = ctx.guard.query;
61+
const grants = await sessionLibrary.findUserActiveApplicationGrants(userId, appType);
62+
63+
ctx.body = {
64+
grants,
65+
};
66+
67+
return next();
68+
}
69+
);
70+
4471
router.get(
4572
'/users/:userId/sessions/:sessionId',
4673
koaGuard({

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
CreatePersonalAccessToken,
33
DesensitizedEnterpriseSsoTokenSetSecret,
44
DesensitizedSocialTokenSetSecret,
5+
GetUserApplicationGrantsResponse,
56
GetUserSessionResponse,
67
GetUserSessionsResponse,
78
Identities,
@@ -231,6 +232,18 @@ export const getUserSsoIdentity = async (
231232
export const getUserSessions = async (userId: string) =>
232233
authedAdminApi.get(`users/${userId}/sessions`).json<GetUserSessionsResponse>();
233234

235+
export const getUserApplicationGrants = async (
236+
userId: string,
237+
appType?: 'firstParty' | 'thirdParty'
238+
) =>
239+
authedAdminApi
240+
.get(`users/${userId}/grants`, {
241+
searchParams: new URLSearchParams({
242+
...conditional(appType && { appType }),
243+
}),
244+
})
245+
.json<GetUserApplicationGrantsResponse>();
246+
234247
export const getUserSession = async (userId: string, sessionId: string) =>
235248
authedAdminApi.get(`users/${userId}/sessions/${sessionId}`).json<GetUserSessionResponse>();
236249

0 commit comments

Comments
 (0)