From 76f31472f5c951c9ee341d3198ee3bfe27320d80 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Fri, 4 Apr 2025 13:35:48 +0800 Subject: [PATCH] feat(core): add management API to delete one-time token --- packages/core/src/queries/one-time-tokens.ts | 4 ++ .../src/routes/one-time-tokens.openapi.json | 9 ++++ packages/core/src/routes/one-time-tokens.ts | 19 +++++++ .../src/api/one-time-token.ts | 3 ++ .../src/tests/api/one-time-tokens.test.ts | 50 ++++++++++++++++++- 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/core/src/queries/one-time-tokens.ts b/packages/core/src/queries/one-time-tokens.ts index fc6316ca0..935301b60 100644 --- a/packages/core/src/queries/one-time-tokens.ts +++ b/packages/core/src/queries/one-time-tokens.ts @@ -2,6 +2,7 @@ import { OneTimeTokens, OneTimeTokenStatus, type OneTimeToken } from '@logto/sch import type { CommonQueryMethods } from '@silverhand/slonik'; import { sql } from '@silverhand/slonik'; +import { buildDeleteByIdWithPool } from '#src/database/delete-by-id.js'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { convertToIdentifiers } from '#src/utils/sql.js'; @@ -15,6 +16,8 @@ export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => { const getOneTimeTokenById = buildFindEntityByIdWithPool(pool)(OneTimeTokens); + const deleteOneTimeTokenById = buildDeleteByIdWithPool(pool, OneTimeTokens.table); + const updateExpiredOneTimeTokensStatusByEmail = async (email: string) => pool.query(sql` update ${table} @@ -40,6 +43,7 @@ export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => { `); return { + deleteOneTimeTokenById, insertOneTimeToken, updateExpiredOneTimeTokensStatusByEmail, getOneTimeTokenById, diff --git a/packages/core/src/routes/one-time-tokens.openapi.json b/packages/core/src/routes/one-time-tokens.openapi.json index d4aed2a64..ebb784bb6 100644 --- a/packages/core/src/routes/one-time-tokens.openapi.json +++ b/packages/core/src/routes/one-time-tokens.openapi.json @@ -48,6 +48,15 @@ "description": "The one-time token found by ID." } } + }, + "delete": { + "summary": "Delete one-time token by ID", + "description": "Delete a one-time token by its ID.", + "responses": { + "204": { + "description": "The one-time token was deleted successfully." + } + } } }, "/api/one-time-tokens/verify": { diff --git a/packages/core/src/routes/one-time-tokens.ts b/packages/core/src/routes/one-time-tokens.ts index 25177ba9e..2d4aa5680 100644 --- a/packages/core/src/routes/one-time-tokens.ts +++ b/packages/core/src/routes/one-time-tokens.ts @@ -17,6 +17,7 @@ export default function oneTimeTokenRoutes( { queries: { oneTimeTokens: { + deleteOneTimeTokenById, insertOneTimeToken, updateExpiredOneTimeTokensStatusByEmail, getOneTimeTokenById, @@ -127,4 +128,22 @@ export default function oneTimeTokenRoutes( return next(); } ); + + router.delete( + '/one-time-tokens/:id', + koaGuard({ + params: z.object({ + id: z.string().min(1), + }), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { params } = ctx.guard; + const { id } = params; + + await deleteOneTimeTokenById(id); + ctx.status = 204; + return next(); + } + ); } diff --git a/packages/integration-tests/src/api/one-time-token.ts b/packages/integration-tests/src/api/one-time-token.ts index d3c94c9a7..b14456ead 100644 --- a/packages/integration-tests/src/api/one-time-token.ts +++ b/packages/integration-tests/src/api/one-time-token.ts @@ -30,3 +30,6 @@ export const updateOneTimeTokenStatus = async (id: string, status: OneTimeTokenS json: { status }, }) .json(); + +export const deleteOneTimeTokenById = async (id: string) => + authedAdminApi.delete(`one-time-tokens/${id}`).json(); diff --git a/packages/integration-tests/src/tests/api/one-time-tokens.test.ts b/packages/integration-tests/src/tests/api/one-time-tokens.test.ts index 10ce0a6f2..948c39b35 100644 --- a/packages/integration-tests/src/tests/api/one-time-tokens.test.ts +++ b/packages/integration-tests/src/tests/api/one-time-tokens.test.ts @@ -6,6 +6,7 @@ import { verifyOneTimeToken, getOneTimeTokenById, updateOneTimeTokenStatus, + deleteOneTimeTokenById, } from '#src/api/one-time-token.js'; import { expectRejects } from '#src/helpers/index.js'; import { devFeatureTest, waitFor } from '#src/utils.js'; @@ -25,6 +26,8 @@ describe('one-time tokens API', () => { expect(oneTimeToken.context).toEqual({}); expect(oneTimeToken.email).toBe(email); expect(oneTimeToken.token.length).toBe(32); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should create one-time token with custom expiration time', async () => { @@ -39,6 +42,8 @@ describe('one-time tokens API', () => { expect(oneTimeToken.status).toBe(OneTimeTokenStatus.Active); expect(oneTimeToken.email).toBe(email); expect(oneTimeToken.token.length).toBe(32); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should create one-time token with `applicationIds` and `jitOrganizationIds` configured', async () => { @@ -56,6 +61,8 @@ describe('one-time tokens API', () => { jitOrganizationIds: ['org-1'], }); expect(oneTimeToken.token.length).toBe(32); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should be able to get one-time token by its ID', async () => { @@ -69,6 +76,8 @@ describe('one-time tokens API', () => { const token = await getOneTimeTokenById(oneTimeToken.id); expect(token).toEqual(oneTimeToken); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should throw when getting a non-existent one-time token', async () => { @@ -86,10 +95,13 @@ describe('one-time tokens API', () => { }); await waitFor(1001); - await createOneTimeToken({ email }); + const newOneTimeToken = await createOneTimeToken({ email }); const reFetchedToken = await getOneTimeTokenById(oneTimeToken.id); expect(reFetchedToken.status).toBe(OneTimeTokenStatus.Expired); + + void deleteOneTimeTokenById(oneTimeToken.id); + void deleteOneTimeTokenById(newOneTimeToken.id); }); it('should verify one-time token', async () => { @@ -122,6 +134,8 @@ describe('one-time tokens API', () => { status: 400, } ); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should not succeed to verify one-time token with expired token', async () => { @@ -147,6 +161,8 @@ describe('one-time tokens API', () => { status: 400, } ); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should not succeed to verify one-time token wrong email', async () => { @@ -168,11 +184,13 @@ describe('one-time tokens API', () => { status: 400, } ); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should not succeed to verify one-time token wrong token', async () => { const email = `foo${generateStandardId()}@bar.com`; - await createOneTimeToken({ + const oneTimeToken = await createOneTimeToken({ email, context: { jitOrganizationIds: ['org-1'], @@ -189,6 +207,7 @@ describe('one-time tokens API', () => { status: 404, } ); + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should throw token_expired error and update token status to expired (token already expired but status is not updated)', async () => { @@ -210,6 +229,8 @@ describe('one-time tokens API', () => { const updatedToken = await getOneTimeTokenById(oneTimeToken.id); expect(updatedToken.status).toBe(OneTimeTokenStatus.Expired); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should be able to revoke a token by updating the status', async () => { @@ -220,6 +241,8 @@ describe('one-time tokens API', () => { const updatedToken = await getOneTimeTokenById(oneTimeToken.id); expect(updatedToken.status).toBe(OneTimeTokenStatus.Revoked); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should throw when trying to re-activate a token', async () => { @@ -230,6 +253,8 @@ describe('one-time tokens API', () => { code: 'one_time_token.cannot_reactivate_token', status: 400, }); + + void deleteOneTimeTokenById(oneTimeToken.id); }); it('should throw when verifying a revoked token', async () => { @@ -248,5 +273,26 @@ describe('one-time tokens API', () => { status: 400, } ); + + void deleteOneTimeTokenById(oneTimeToken.id); + }); + + it('should throw when trying to delete a non-existent one-time token', async () => { + await expectRejects(deleteOneTimeTokenById('non-existent-id'), { + code: 'entity.not_found', + status: 404, + }); + }); + + it('should delete the one-time token successfully', async () => { + const email = `foo${generateStandardId()}@bar.com`; + const oneTimeToken = await createOneTimeToken({ email }); + + await deleteOneTimeTokenById(oneTimeToken.id); + + await expectRejects(getOneTimeTokenById(oneTimeToken.id), { + code: 'entity.not_exists_with_id', + status: 404, + }); }); });