Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): create PAT #6388

Merged
merged 1 commit into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/core/src/middleware/koa-slonik-error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
status: 422,
});
}

if (error.constraint === 'personal_access_tokens_pkey') {
throw new RequestError({
code: 'user.personal_access_token_name_exists',
status: 422,
});
}

Check warning on line 59 in packages/core/src/middleware/koa-slonik-error-handler.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/middleware/koa-slonik-error-handler.ts#L53-L59

Added lines #L53 - L59 were not covered by tests
}

if (error instanceof CheckIntegrityConstraintViolationError) {
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/queries/personal-access-tokens.ts
Original file line number Diff line number Diff line change
@@ -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}
`);
}

Check warning on line 27 in packages/core/src/queries/personal-access-tokens.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/personal-access-tokens.ts#L22-L27

Added lines #L22 - L27 were not covered by tests

async getTokensByUserId(userId: string) {
return this.pool.any<PersonalAccessToken>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.userId} = ${userId}
`);
}

Check warning on line 35 in packages/core/src/queries/personal-access-tokens.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/personal-access-tokens.ts#L30-L35

Added lines #L30 - L35 were not covered by tests

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

Check warning on line 46 in packages/core/src/queries/personal-access-tokens.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/personal-access-tokens.ts#L38-L46

Added lines #L38 - L46 were not covered by tests
}
5 changes: 5 additions & 0 deletions packages/core/src/routes/admin-user/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,4 +16,7 @@ export default function adminUserRoutes<T extends ManagementApiRouter>(...args:
adminUserSocialRoutes(...args);
adminUserOrganizationRoutes(...args);
adminUserMfaVerificationsRoutes(...args);
if (EnvSet.values.isDevFeaturesEnabled) {
adminUserPersonalAccessTokenRoutes(...args);
}
}
Original file line number Diff line number Diff line change
@@ -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 changes: 46 additions & 0 deletions packages/core/src/routes/admin-user/personal-access-token.ts
Original file line number Diff line number Diff line change
@@ -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()}`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: wondering would it be helpful if we put the userId as a prefix, e.g. `pat_${uid}_${generateStandardSecret()}

});
ctx.status = 201;

return next();
}

Check warning on line 44 in packages/core/src/routes/admin-user/personal-access-token.ts

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L23 - L44 were not covered by tests
);
}
3 changes: 3 additions & 0 deletions packages/core/src/tenants/Queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions packages/integration-tests/src/api/admin-user.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type {
CreatePersonalAccessToken,
Identities,
Identity,
MfaFactor,
MfaVerification,
OrganizationWithRoles,
PersonalAccessToken,
Role,
User,
UserSsoIdentity,
Expand Down Expand Up @@ -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>();
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions packages/phrases/src/locales/en/errors/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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