From 86e76ffc8a38ece1ba3c74c98d2a3592f2885764 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Tue, 13 Aug 2024 10:10:41 +0800 Subject: [PATCH] feat(core): create PAT (#6388) --- .../middleware/koa-slonik-error-handler.ts | 7 ++ .../src/queries/personal-access-tokens.ts | 47 ++++++++++ packages/core/src/routes/admin-user/index.ts | 5 ++ .../personal-access-token.openapi.json | 39 +++++++++ .../admin-user/personal-access-token.ts | 46 ++++++++++ packages/core/src/tenants/Queries.ts | 3 + .../integration-tests/src/api/admin-user.ts | 10 +++ .../admin-user.personal-access-tokens.test.ts | 85 +++++++++++++++++++ .../phrases/src/locales/en/errors/user.ts | 1 + 9 files changed, 243 insertions(+) create mode 100644 packages/core/src/queries/personal-access-tokens.ts create mode 100644 packages/core/src/routes/admin-user/personal-access-token.openapi.json create mode 100644 packages/core/src/routes/admin-user/personal-access-token.ts create mode 100644 packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts diff --git a/packages/core/src/middleware/koa-slonik-error-handler.ts b/packages/core/src/middleware/koa-slonik-error-handler.ts index 520e8a1bc..15e59e976 100644 --- a/packages/core/src/middleware/koa-slonik-error-handler.ts +++ b/packages/core/src/middleware/koa-slonik-error-handler.ts @@ -50,6 +50,13 @@ export default function koaSlonikErrorHandler(): Middleware(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.value} = ${value} + `); + } + + async getTokensByUserId(userId: string) { + return this.pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.userId} = ${userId} + `); + } + + async deleteByName(appId: string, name: string) { + const { rowCount } = await this.pool.query(sql` + delete from ${table} + where ${fields.userId} = ${appId} + and ${fields.name} = ${name} + `); + if (rowCount < 1) { + throw new DeletionError(PersonalAccessTokens.table, name); + } + } +} diff --git a/packages/core/src/routes/admin-user/index.ts b/packages/core/src/routes/admin-user/index.ts index 2f57e089d..f3adcf56e 100644 --- a/packages/core/src/routes/admin-user/index.ts +++ b/packages/core/src/routes/admin-user/index.ts @@ -1,8 +1,10 @@ +import { EnvSet } from '../../env-set/index.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; import adminUserBasicsRoutes from './basics.js'; import adminUserMfaVerificationsRoutes from './mfa-verifications.js'; import adminUserOrganizationRoutes from './organization.js'; +import adminUserPersonalAccessTokenRoutes from './personal-access-token.js'; import adminUserRoleRoutes from './role.js'; import adminUserSearchRoutes from './search.js'; import adminUserSocialRoutes from './social.js'; @@ -14,4 +16,7 @@ export default function adminUserRoutes(...args: adminUserSocialRoutes(...args); adminUserOrganizationRoutes(...args); adminUserMfaVerificationsRoutes(...args); + if (EnvSet.values.isDevFeaturesEnabled) { + adminUserPersonalAccessTokenRoutes(...args); + } } diff --git a/packages/core/src/routes/admin-user/personal-access-token.openapi.json b/packages/core/src/routes/admin-user/personal-access-token.openapi.json new file mode 100644 index 000000000..bfed0ee1c --- /dev/null +++ b/packages/core/src/routes/admin-user/personal-access-token.openapi.json @@ -0,0 +1,39 @@ +{ + "tags": [ + { + "name": "Dev feature" + } + ], + "paths": { + "/api/users/{userId}/personal-access-tokens": { + "post": { + "summary": "Add personal access token", + "description": "Add a new personal access token for the user.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "description": "The personal access token name. Must be unique within the user." + }, + "expiresAt": { + "description": "The epoch time in milliseconds when the token will expire. If not provided, the token will never expire." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The personal access token was added successfully." + }, + "422": { + "description": "The personal access token name is already in use." + } + } + } + } + } +} diff --git a/packages/core/src/routes/admin-user/personal-access-token.ts b/packages/core/src/routes/admin-user/personal-access-token.ts new file mode 100644 index 000000000..6a0eada97 --- /dev/null +++ b/packages/core/src/routes/admin-user/personal-access-token.ts @@ -0,0 +1,46 @@ +import { PersonalAccessTokens } from '@logto/schemas'; +import { generateStandardSecret } from '@logto/shared'; +import { z } 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 ManagementApiRouter, type RouterInitArgs } from '../types.js'; + +export default function adminUserPersonalAccessTokenRoutes( + ...[router, { queries }]: RouterInitArgs +) { + router.post( + '/users/:userId/personal-access-tokens', + koaGuard({ + params: z.object({ userId: z.string() }), + body: PersonalAccessTokens.createGuard.pick({ name: true, expiresAt: true }), + response: PersonalAccessTokens.guard, + status: [201, 400], + }), + async (ctx, next) => { + const { + params: { userId }, + body, + } = ctx.guard; + + assertThat( + !body.expiresAt || body.expiresAt > Date.now(), + new RequestError({ + code: 'request.invalid_input', + details: 'The value of `expiresAt` must be in the future.', + }) + ); + + ctx.body = await queries.personalAccessTokens.insert({ + ...body, + userId, + value: `pat_${generateStandardSecret()}`, + }); + ctx.status = 201; + + return next(); + } + ); +} diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index f7982b014..e776f6ba3 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -29,6 +29,8 @@ import { createUserQueries } from '#src/queries/user.js'; import { createUsersRolesQueries } from '#src/queries/users-roles.js'; import { createVerificationStatusQueries } from '#src/queries/verification-status.js'; +import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js'; + export default class Queries { applications = createApplicationQueries(this.pool); applicationSecrets = new ApplicationSecretQueries(this.pool); @@ -56,6 +58,7 @@ export default class Queries { ssoConnectors = new SsoConnectorQueries(this.pool); userSsoIdentities = new UserSsoIdentityQueries(this.pool); subjectTokens = createSubjectTokenQueries(this.pool); + personalAccessTokens = new PersonalAccessTokensQueries(this.pool); tenants = createTenantQueries(this.pool); constructor( diff --git a/packages/integration-tests/src/api/admin-user.ts b/packages/integration-tests/src/api/admin-user.ts index 7c9ad0360..5cd33180b 100644 --- a/packages/integration-tests/src/api/admin-user.ts +++ b/packages/integration-tests/src/api/admin-user.ts @@ -1,9 +1,11 @@ import type { + CreatePersonalAccessToken, Identities, Identity, MfaFactor, MfaVerification, OrganizationWithRoles, + PersonalAccessToken, Role, User, UserSsoIdentity, @@ -130,3 +132,11 @@ export const createUserMfaVerification = async (userId: string, type: MfaFactor) export const getUserOrganizations = async (userId: string) => authedAdminApi.get(`users/${userId}/organizations`).json(); + +export const createPersonalAccessToken = async ({ + userId, + ...body +}: Omit) => + authedAdminApi + .post(`users/${userId}/personal-access-tokens`, { json: body }) + .json(); diff --git a/packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts b/packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts new file mode 100644 index 000000000..3769eb8fa --- /dev/null +++ b/packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts @@ -0,0 +1,85 @@ +import { HTTPError } from 'ky'; + +import { createPersonalAccessToken, deleteUser } from '#src/api/admin-user.js'; +import { createUserByAdmin } from '#src/helpers/index.js'; +import { devFeatureTest, randomString } from '#src/utils.js'; + +devFeatureTest.describe('personal access tokens', () => { + it('should throw error when creating PAT with existing name', async () => { + const user = await createUserByAdmin(); + const name = randomString(); + await createPersonalAccessToken({ userId: user.id, name }); + + const response = await createPersonalAccessToken({ userId: user.id, name }).catch( + (error: unknown) => error + ); + + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 422); + expect(await (response as HTTPError).response.json()).toHaveProperty( + 'code', + 'user.personal_access_token_name_exists' + ); + + await deleteUser(user.id); + }); + + it('should throw error when creating PAT with invalid user id', async () => { + const name = randomString(); + const response = await createPersonalAccessToken({ + userId: 'invalid', + name, + }).catch((error: unknown) => error); + + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 404); + }); + + it('should throw error when creating PAT with empty name', async () => { + const user = await createUserByAdmin(); + const response = await createPersonalAccessToken({ + userId: user.id, + name: '', + }).catch((error: unknown) => error); + + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 400); + + await deleteUser(user.id); + }); + + it('should throw error when creating PAT with invalid expiresAt', async () => { + const user = await createUserByAdmin(); + const name = randomString(); + const response = await createPersonalAccessToken({ + userId: user.id, + name, + expiresAt: Date.now() - 1000, + }).catch((error: unknown) => error); + + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 400); + + await deleteUser(user.id); + }); + + it('should be able to create multiple PATs', async () => { + const user = await createUserByAdmin(); + const name1 = randomString(); + const name2 = randomString(); + const pat1 = await createPersonalAccessToken({ + userId: user.id, + name: name1, + expiresAt: Date.now() + 1000, + }); + const pat2 = await createPersonalAccessToken({ + userId: user.id, + name: name2, + }); + + expect(pat1).toHaveProperty('name', name1); + expect(pat2).toHaveProperty('name', name2); + + await deleteUser(user.id); + }); +}); diff --git a/packages/phrases/src/locales/en/errors/user.ts b/packages/phrases/src/locales/en/errors/user.ts index cff11815b..bd95b5f10 100644 --- a/packages/phrases/src/locales/en/errors/user.ts +++ b/packages/phrases/src/locales/en/errors/user.ts @@ -34,6 +34,7 @@ const user = { backup_code_already_in_use: 'Backup code is already in use.', password_algorithm_required: 'Password algorithm is required.', password_and_digest: 'You cannot set both plain text password and password digest.', + personal_access_token_name_exists: 'Personal access token name already exists.', }; export default Object.freeze(user);