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,
|
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) {
|
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 type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
import adminUserBasicsRoutes from './basics.js';
|
import adminUserBasicsRoutes from './basics.js';
|
||||||
import adminUserMfaVerificationsRoutes from './mfa-verifications.js';
|
import adminUserMfaVerificationsRoutes from './mfa-verifications.js';
|
||||||
import adminUserOrganizationRoutes from './organization.js';
|
import adminUserOrganizationRoutes from './organization.js';
|
||||||
|
import adminUserPersonalAccessTokenRoutes from './personal-access-token.js';
|
||||||
import adminUserRoleRoutes from './role.js';
|
import adminUserRoleRoutes from './role.js';
|
||||||
import adminUserSearchRoutes from './search.js';
|
import adminUserSearchRoutes from './search.js';
|
||||||
import adminUserSocialRoutes from './social.js';
|
import adminUserSocialRoutes from './social.js';
|
||||||
|
@ -14,4 +16,7 @@ export default function adminUserRoutes<T extends ManagementApiRouter>(...args:
|
||||||
adminUserSocialRoutes(...args);
|
adminUserSocialRoutes(...args);
|
||||||
adminUserOrganizationRoutes(...args);
|
adminUserOrganizationRoutes(...args);
|
||||||
adminUserMfaVerificationsRoutes(...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 { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
||||||
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
||||||
|
|
||||||
|
import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js';
|
||||||
|
|
||||||
export default class Queries {
|
export default class Queries {
|
||||||
applications = createApplicationQueries(this.pool);
|
applications = createApplicationQueries(this.pool);
|
||||||
applicationSecrets = new ApplicationSecretQueries(this.pool);
|
applicationSecrets = new ApplicationSecretQueries(this.pool);
|
||||||
|
@ -56,6 +58,7 @@ export default class Queries {
|
||||||
ssoConnectors = new SsoConnectorQueries(this.pool);
|
ssoConnectors = new SsoConnectorQueries(this.pool);
|
||||||
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
|
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
|
||||||
subjectTokens = createSubjectTokenQueries(this.pool);
|
subjectTokens = createSubjectTokenQueries(this.pool);
|
||||||
|
personalAccessTokens = new PersonalAccessTokensQueries(this.pool);
|
||||||
tenants = createTenantQueries(this.pool);
|
tenants = createTenantQueries(this.pool);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import type {
|
import type {
|
||||||
|
CreatePersonalAccessToken,
|
||||||
Identities,
|
Identities,
|
||||||
Identity,
|
Identity,
|
||||||
MfaFactor,
|
MfaFactor,
|
||||||
MfaVerification,
|
MfaVerification,
|
||||||
OrganizationWithRoles,
|
OrganizationWithRoles,
|
||||||
|
PersonalAccessToken,
|
||||||
Role,
|
Role,
|
||||||
User,
|
User,
|
||||||
UserSsoIdentity,
|
UserSsoIdentity,
|
||||||
|
@ -130,3 +132,11 @@ export const createUserMfaVerification = async (userId: string, type: MfaFactor)
|
||||||
|
|
||||||
export const getUserOrganizations = async (userId: string) =>
|
export const getUserOrganizations = async (userId: string) =>
|
||||||
authedAdminApi.get(`users/${userId}/organizations`).json<OrganizationWithRoles[]>();
|
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.',
|
backup_code_already_in_use: 'Backup code is already in use.',
|
||||||
password_algorithm_required: 'Password algorithm is required.',
|
password_algorithm_required: 'Password algorithm is required.',
|
||||||
password_and_digest: 'You cannot set both plain text password and password digest.',
|
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);
|
export default Object.freeze(user);
|
||||||
|
|
Loading…
Reference in a new issue