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:
parent
c791847879
commit
86e76ffc8a
9 changed files with 243 additions and 0 deletions
|
@ -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) {
|
||||
|
|
47
packages/core/src/queries/personal-access-tokens.ts
Normal file
47
packages/core/src/queries/personal-access-tokens.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
46
packages/core/src/routes/admin-user/personal-access-token.ts
Normal file
46
packages/core/src/routes/admin-user/personal-access-token.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue