Skip to content

Commit

Permalink
feat(core): create PAT
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Aug 12, 2024
1 parent b42b37b commit 9e34118
Show file tree
Hide file tree
Showing 9 changed files with 243 additions and 0 deletions.
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 @@ 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,
});
}

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()}`,
});
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);

0 comments on commit 9e34118

Please sign in to comment.