Skip to content

Commit bb495ef

Browse files
authored
refactor(core,console): use body for PAT operations (#7900)
refactor(core,console): use body for pat operations fixed #7760
1 parent 2783368 commit bb495ef

File tree

9 files changed

+184
-13
lines changed

9 files changed

+184
-13
lines changed

.changeset/mean-impalas-joke.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@logto/console": patch
3+
"@logto/core": patch
4+
---
5+
6+
add body-based personal access token APIs
7+
8+
introduce PATCH/POST endpoints that accept token names in the request body to support special characters while keeping path-based routes for compatibility:
9+
- PATCH /api/users/{userId}/personal-access-tokens
10+
- POST /api/users/{userId}/personal-access-tokens/delete

packages/console/src/pages/UserDetails/UserSettings/PersonalAccessTokens/EditTokenModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ function EditTokenModal({ userId, token, onClose }: Props) {
4141
const submit = handleSubmit(
4242
trySubmitSafe(async (data) => {
4343
const createdData = await api
44-
.patch(`api/users/${userId}/personal-access-tokens/${encodeURIComponent(token.name)}`, {
45-
json: data,
44+
.patch(`api/users/${userId}/personal-access-tokens`, {
45+
json: { currentName: token.name, name: data.name },
4646
})
4747
.json<PersonalAccessToken>();
4848
toast.success(

packages/console/src/pages/UserDetails/UserSettings/PersonalAccessTokens/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ function PersonalAccessTokens({ userId }: Props) {
6969
fieldName="user_details.personal_access_tokens.title_short"
7070
deleteConfirmation="user_details.personal_access_tokens.delete_confirmation"
7171
onDelete={async () => {
72-
await api.delete(
73-
`api/users/${userId}/personal-access-tokens/${encodeURIComponent(token.name)}`
74-
);
72+
await api.post(`api/users/${userId}/personal-access-tokens/delete`, {
73+
json: { name: token.name },
74+
});
7575
void mutate();
7676
}}
7777
onEdit={() => {

packages/core/src/queries/personal-access-tokens.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ export class PersonalAccessTokensQueries {
4242
`);
4343
}
4444

45-
async deleteByName(appId: string, name: string) {
45+
async deleteByName(userId: string, name: string) {
4646
const { rowCount } = await this.pool.query(sql`
4747
delete from ${table}
48-
where ${fields.userId} = ${appId}
48+
where ${fields.userId} = ${userId}
4949
and ${fields.name} = ${name}
5050
`);
5151
if (rowCount < 1) {

packages/core/src/routes/admin-user/personal-access-token.openapi.json

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,38 @@
3737
"description": "The personal access token name is already in use."
3838
}
3939
}
40+
},
41+
"patch": {
42+
"operationId": "UpdatePersonalAccessTokenName",
43+
"summary": "Update personal access token",
44+
"description": "Update a token for the user by name.",
45+
"requestBody": {
46+
"content": {
47+
"application/json": {
48+
"schema": {
49+
"properties": {
50+
"currentName": {
51+
"description": "The current name of the token to update."
52+
},
53+
"name": {
54+
"description": "The new token name. Must be unique within the user."
55+
}
56+
}
57+
}
58+
}
59+
}
60+
},
61+
"responses": {
62+
"200": {
63+
"description": "The token was updated successfully."
64+
}
65+
}
4066
}
4167
},
4268
"/api/users/{userId}/personal-access-tokens/{name}": {
4369
"delete": {
4470
"summary": "Delete personal access token",
45-
"description": "Delete a token for the user by name.",
71+
"description": "Delete a token for the user by name using the legacy path parameter. Deprecated: use the POST /delete endpoint instead to avoid url name encoding issues.",
4672
"parameters": [
4773
{
4874
"name": "name",
@@ -58,12 +84,12 @@
5884
},
5985
"patch": {
6086
"summary": "Update personal access token",
61-
"description": "Update a token for the user by name.",
87+
"description": "Update a token for the user by name using the legacy path parameter. Deprecated: use the PATCH /personal-access-tokens endpoint instead to avoid url name encoding issues.",
6288
"parameters": [
6389
{
6490
"name": "name",
6591
"in": "path",
66-
"description": "The name of the token."
92+
"description": "The current name of the token."
6793
}
6894
],
6995
"requestBody": {
@@ -72,19 +98,44 @@
7298
"schema": {
7399
"properties": {
74100
"name": {
75-
"description": "The token name to update. Must be unique within the user."
101+
"description": "The new token name. Must be unique within the user."
76102
}
77103
}
78104
}
79105
}
80106
}
81107
},
82108
"responses": {
83-
"204": {
109+
"200": {
84110
"description": "The token was updated successfully."
85111
}
86112
}
87113
}
114+
},
115+
"/api/users/{userId}/personal-access-tokens/delete": {
116+
"post": {
117+
"operationId": "DeletePersonalAccessTokenPost",
118+
"summary": "Delete personal access token",
119+
"description": "Delete a token for the user by name.",
120+
"requestBody": {
121+
"content": {
122+
"application/json": {
123+
"schema": {
124+
"properties": {
125+
"name": {
126+
"description": "The name of the token to delete."
127+
}
128+
}
129+
}
130+
}
131+
}
132+
},
133+
"responses": {
134+
"204": {
135+
"description": "The token was deleted successfully."
136+
}
137+
}
138+
}
88139
}
89140
}
90141
}

packages/core/src/routes/admin-user/personal-access-token.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export default function adminUserPersonalAccessTokenRoutes<T extends ManagementA
6060
}
6161
);
6262

63+
/**
64+
* Legacy endpoints that accept the token name via the request path.
65+
* Keep them for backward compatibility.
66+
*/
6367
router.delete(
6468
'/users/:userId/personal-access-tokens/:name',
6569
koaGuard({
@@ -78,6 +82,29 @@ export default function adminUserPersonalAccessTokenRoutes<T extends ManagementA
7882
}
7983
);
8084

85+
// Here we use POST method to avoid potential issues with sending body in DELETE requests.
86+
router.post(
87+
'/users/:userId/personal-access-tokens/delete',
88+
koaGuard({
89+
params: z.object({ userId: z.string() }),
90+
body: z.object({ name: z.string() }),
91+
status: [204, 404],
92+
}),
93+
async (ctx, next) => {
94+
const {
95+
params: { userId },
96+
body: { name },
97+
} = ctx.guard;
98+
99+
await queries.personalAccessTokens.deleteByName(userId, name);
100+
ctx.status = 204;
101+
102+
return next();
103+
}
104+
);
105+
106+
// Legacy endpoint that accepts the token name via the request path.
107+
// Keep it for backward compatibility.
81108
router.patch(
82109
'/users/:userId/personal-access-tokens/:name',
83110
koaGuard({
@@ -96,4 +123,34 @@ export default function adminUserPersonalAccessTokenRoutes<T extends ManagementA
96123
return next();
97124
}
98125
);
126+
127+
router.patch(
128+
'/users/:userId/personal-access-tokens',
129+
koaGuard({
130+
params: z.object({ userId: z.string() }),
131+
body: PersonalAccessTokens.updateGuard
132+
.pick({ name: true })
133+
.required()
134+
.extend({ currentName: z.string().optional() }),
135+
response: PersonalAccessTokens.guard,
136+
status: [200, 400, 404],
137+
}),
138+
async (ctx, next) => {
139+
const {
140+
params: { userId },
141+
body: { currentName, name },
142+
} = ctx.guard;
143+
144+
/**
145+
* `currentName` is optional for backward compatibility with clients that
146+
* already send the current name via other channels (for example, in the
147+
* request URL). When it is omitted we assume the caller is updating the
148+
* token in place without renaming.
149+
*/
150+
const targetName = currentName ?? name;
151+
152+
ctx.body = await queries.personalAccessTokens.updateName(userId, targetName, name);
153+
return next();
154+
}
155+
);
99156
}

packages/core/src/routes/swagger/utils/operation-id.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export const customRoutes: Readonly<RouteDictionary> = Object.freeze({
7070
// Users
7171
'post /users/:userId/roles': 'AssignUserRoles',
7272
'post /users/:userId/password/verify': 'VerifyUserPassword',
73+
'post /users/:userId/personal-access-tokens/delete': 'DeletePersonalAccessTokenByName',
74+
'patch /users/:userId/personal-access-tokens': 'UpdatePersonalAccessTokenByName',
7375
// Dashboard
7476
'get /dashboard/users/total': 'GetTotalUserCount',
7577
'get /dashboard/users/new': 'GetNewUserCounts',

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,26 @@ export const getUserPersonalAccessTokens = async (userId: string) =>
155155
authedAdminApi.get(`users/${userId}/personal-access-tokens`).json<PersonalAccessToken[]>();
156156

157157
export const deletePersonalAccessToken = async (userId: string, name: string) =>
158-
authedAdminApi.delete(`users/${userId}/personal-access-tokens/${name}`);
158+
authedAdminApi.post(`users/${userId}/personal-access-tokens/delete`, { json: { name } });
159159

160160
export const updatePersonalAccessToken = async (
161161
userId: string,
162162
name: string,
163163
body: Record<string, unknown>
164+
) =>
165+
authedAdminApi
166+
.patch(`users/${userId}/personal-access-tokens`, {
167+
json: { ...body, currentName: name },
168+
})
169+
.json<PersonalAccessToken>();
170+
171+
export const deletePersonalAccessTokenLegacy = async (userId: string, name: string) =>
172+
authedAdminApi.delete(`users/${userId}/personal-access-tokens/${name}`);
173+
174+
export const updatePersonalAccessTokenLegacy = async (
175+
userId: string,
176+
name: string,
177+
body: Record<string, unknown>
164178
) =>
165179
authedAdminApi
166180
.patch(`users/${userId}/personal-access-tokens/${name}`, {

packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { HTTPError } from 'ky';
33
import {
44
createPersonalAccessToken,
55
deletePersonalAccessToken,
6+
deletePersonalAccessTokenLegacy,
67
deleteUser,
78
getUserPersonalAccessTokens,
89
updatePersonalAccessToken,
10+
updatePersonalAccessTokenLegacy,
911
} from '#src/api/admin-user.js';
1012
import { createUserByAdmin } from '#src/helpers/index.js';
1113
import { randomString } from '#src/utils.js';
@@ -112,4 +114,39 @@ describe('personal access tokens', () => {
112114

113115
await deleteUser(user.id);
114116
});
117+
118+
it('should handle special characters in token name with body-based endpoints', async () => {
119+
const user = await createUserByAdmin();
120+
const nameWithSpecialChars = `token-with-special-chars-${randomString()}!@#$%`;
121+
const pat = await createPersonalAccessToken({
122+
userId: user.id,
123+
name: nameWithSpecialChars,
124+
});
125+
126+
expect(pat.name).toBe(nameWithSpecialChars);
127+
128+
const newName = `updated-${randomString()}`;
129+
const updatedPat = await updatePersonalAccessToken(user.id, pat.name, { name: newName });
130+
expect(updatedPat.name).toBe(newName);
131+
132+
await deletePersonalAccessToken(user.id, updatedPat.name);
133+
expect(await getUserPersonalAccessTokens(user.id)).toEqual([]);
134+
135+
await deleteUser(user.id);
136+
});
137+
138+
it('should support legacy path-based endpoints', async () => {
139+
const user = await createUserByAdmin();
140+
const name = randomString();
141+
await createPersonalAccessToken({ userId: user.id, name });
142+
143+
const newName = randomString();
144+
const updated = await updatePersonalAccessTokenLegacy(user.id, name, { name: newName });
145+
expect(updated.name).toBe(newName);
146+
147+
await deletePersonalAccessTokenLegacy(user.id, newName);
148+
expect(await getUserPersonalAccessTokens(user.id)).toEqual([]);
149+
150+
await deleteUser(user.id);
151+
});
115152
});

0 commit comments

Comments
 (0)