0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): create PAT (#6388)

This commit is contained in:
wangsijie 2024-08-13 10:10:41 +08:00 committed by GitHub
parent c791847879
commit 86e76ffc8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 243 additions and 0 deletions

View file

@ -50,6 +50,13 @@ export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<St
status: 422,
});
}
if (error.constraint === 'personal_access_tokens_pkey') {
throw new RequestError({
code: 'user.personal_access_token_name_exists',
status: 422,
});
}
}
if (error instanceof CheckIntegrityConstraintViolationError) {

View file

@ -0,0 +1,47 @@
import { type PersonalAccessToken, PersonalAccessTokens } from '@logto/schemas';
import { type CommonQueryMethods, sql } from '@silverhand/slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { DeletionError } from '#src/errors/SlonikError/index.js';
import { convertToIdentifiers } from '#src/utils/sql.js';
import { buildUpdateWhereWithPool } from '../database/update-where.js';
const { table, fields } = convertToIdentifiers(PersonalAccessTokens);
export class PersonalAccessTokensQueries {
public readonly insert = buildInsertIntoWithPool(this.pool)(PersonalAccessTokens, {
returning: true,
});
public readonly update = buildUpdateWhereWithPool(this.pool)(PersonalAccessTokens, true);
constructor(public readonly pool: CommonQueryMethods) {}
async findByValue(value: string) {
return this.pool.maybeOne<PersonalAccessToken>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.value} = ${value}
`);
}
async getTokensByUserId(userId: string) {
return this.pool.any<PersonalAccessToken>(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);
}
}
}

View file

@ -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<T extends ManagementApiRouter>(...args:
adminUserSocialRoutes(...args);
adminUserOrganizationRoutes(...args);
adminUserMfaVerificationsRoutes(...args);
if (EnvSet.values.isDevFeaturesEnabled) {
adminUserPersonalAccessTokenRoutes(...args);
}
}

View file

@ -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."
}
}
}
}
}
}

View file

@ -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<T extends ManagementApiRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
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();
}
);
}

View file

@ -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(

View file

@ -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<OrganizationWithRoles[]>();
export const createPersonalAccessToken = async ({
userId,
...body
}: Omit<CreatePersonalAccessToken, 'value'>) =>
authedAdminApi
.post(`users/${userId}/personal-access-tokens`, { json: body })
.json<PersonalAccessToken>();

View file

@ -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);
});
});

View file

@ -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);