diff --git a/packages/core/src/libraries/one-time-token.ts b/packages/core/src/libraries/one-time-token.ts index 7befded21..762060a9f 100644 --- a/packages/core/src/libraries/one-time-token.ts +++ b/packages/core/src/libraries/one-time-token.ts @@ -5,9 +5,17 @@ import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; export const createOneTimeTokenLibrary = (queries: Queries) => { - const { updateOneTimeTokenStatus: updateOneTimeTokenStatusQuery, getOneTimeTokenByToken } = - queries.oneTimeTokens; + const { + updateOneTimeTokenStatus: updateOneTimeTokenStatusQuery, + getOneTimeTokenById, + getOneTimeTokenByToken, + } = queries.oneTimeTokens; + const updateOneTimeTokenStatusById = async (id: string, status: OneTimeTokenStatus) => { + const oneTimeTokenRecord = await getOneTimeTokenById(id); + + return updateOneTimeTokenStatus(oneTimeTokenRecord.token, status); + }; const updateOneTimeTokenStatus = async (token: string, status: OneTimeTokenStatus) => { assertThat(status !== OneTimeTokenStatus.Active, 'one_time_token.cannot_reactivate_token'); @@ -48,5 +56,10 @@ export const createOneTimeTokenLibrary = (queries: Queries) => { return updateOneTimeTokenStatus(token, OneTimeTokenStatus.Consumed); }; - return { checkOneTimeToken, updateOneTimeTokenStatus, verifyOneTimeToken }; + return { + checkOneTimeToken, + updateOneTimeTokenStatus, + updateOneTimeTokenStatusById, + verifyOneTimeToken, + }; }; diff --git a/packages/core/src/queries/one-time-tokens.ts b/packages/core/src/queries/one-time-tokens.ts index ee7486c2a..fc6316ca0 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 { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { convertToIdentifiers } from '#src/utils/sql.js'; @@ -12,6 +13,8 @@ export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => { returning: true, }); + const getOneTimeTokenById = buildFindEntityByIdWithPool(pool)(OneTimeTokens); + const updateExpiredOneTimeTokensStatusByEmail = async (email: string) => pool.query(sql` update ${table} @@ -39,6 +42,7 @@ export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => { return { insertOneTimeToken, updateExpiredOneTimeTokensStatusByEmail, + getOneTimeTokenById, getOneTimeTokenByToken, updateOneTimeTokenStatus, }; diff --git a/packages/core/src/routes/one-time-tokens.openapi.json b/packages/core/src/routes/one-time-tokens.openapi.json index 2e4734a5e..d4aed2a64 100644 --- a/packages/core/src/routes/one-time-tokens.openapi.json +++ b/packages/core/src/routes/one-time-tokens.openapi.json @@ -39,6 +39,17 @@ } } }, + "/api/one-time-tokens/{id}": { + "get": { + "summary": "Get one-time token by ID", + "description": "Get a one-time token by its ID.", + "responses": { + "200": { + "description": "The one-time token found by ID." + } + } + } + }, "/api/one-time-tokens/verify": { "post": { "summary": "Verify one-time token", @@ -62,12 +73,30 @@ "responses": { "200": { "description": "The one-time token was verified successfully." - }, - "400": { - "description": "The token has been consumed or is expired, or the email does not match." - }, - "404": { - "description": "The one-time token was not found or is not active." + } + } + } + }, + "/api/one-time-tokens/{id}/status": { + "put": { + "summary": "Update one-time token status", + "description": "Update the status of a one-time token by its ID. This can be used to mark the token as consumed or expired.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "status": { + "description": "The new status of the one-time token." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The one-time token status was updated successfully." } } } diff --git a/packages/core/src/routes/one-time-tokens.ts b/packages/core/src/routes/one-time-tokens.ts index 96b1f5042..25177ba9e 100644 --- a/packages/core/src/routes/one-time-tokens.ts +++ b/packages/core/src/routes/one-time-tokens.ts @@ -1,4 +1,4 @@ -import { OneTimeTokens } from '@logto/schemas'; +import { OneTimeTokens, oneTimeTokenStatusGuard } from '@logto/schemas'; import { generateStandardId, generateStandardSecret } from '@logto/shared'; import { trySafe } from '@silverhand/essentials'; import { addSeconds } from 'date-fns'; @@ -16,14 +16,37 @@ export default function oneTimeTokenRoutes( router, { queries: { - oneTimeTokens: { insertOneTimeToken, updateExpiredOneTimeTokensStatusByEmail }, + oneTimeTokens: { + insertOneTimeToken, + updateExpiredOneTimeTokensStatusByEmail, + getOneTimeTokenById, + }, }, libraries: { - oneTimeTokens: { verifyOneTimeToken }, + oneTimeTokens: { verifyOneTimeToken, updateOneTimeTokenStatusById }, }, }, ]: RouterInitArgs ) { + router.get( + '/one-time-tokens/:id', + koaGuard({ + params: z.object({ + id: z.string().min(1), + }), + response: OneTimeTokens.guard, + status: [200, 400, 404], + }), + async (ctx, next) => { + const { params } = ctx.guard; + const { id } = params; + + const oneTimeToken = await getOneTimeTokenById(id); + ctx.body = oneTimeToken; + ctx.status = 200; + return next(); + } + ); router.post( '/one-time-tokens', koaGuard({ @@ -45,7 +68,6 @@ export default function oneTimeTokenRoutes( const { body } = ctx.guard; const { expiresIn, ...rest } = body; - // TODO: add an integration test for this, once GET API is added. void trySafe(async () => updateExpiredOneTimeTokensStatusByEmail(rest.email)); const expiresAt = addSeconds(new Date(), expiresIn ?? defaultExpiresTime); @@ -81,4 +103,28 @@ export default function oneTimeTokenRoutes( return next(); } ); + + router.put( + '/one-time-tokens/:id/status', + koaGuard({ + params: z.object({ + id: z.string().min(1), + }), + body: z.object({ + status: oneTimeTokenStatusGuard, + }), + response: OneTimeTokens.guard, + status: [200, 400, 404], + }), + async (ctx, next) => { + const { params, body } = ctx.guard; + const { id } = params; + const { status } = body; + + const oneTimeToken = await updateOneTimeTokenStatusById(id, status); + ctx.body = oneTimeToken; + ctx.status = 200; + return next(); + } + ); } diff --git a/packages/core/src/routes/swagger/utils/documents.ts b/packages/core/src/routes/swagger/utils/documents.ts index f3386e963..164802ed7 100644 --- a/packages/core/src/routes/swagger/utils/documents.ts +++ b/packages/core/src/routes/swagger/utils/documents.ts @@ -27,7 +27,8 @@ import { } from './general.js'; import { buildPathIdParameters, customParameters, mergeParameters } from './parameters.js'; -// Add more components here to cover more ID parameters in paths. For example, if there is a +// Add more components here to cover more ID parameters in paths. For example, if there is a new API +// identifiable entity `/api/entities`, and you want to use `/api/entities/{id}`, add the entity here. const managementApiIdentifiableEntityNames = Object.freeze([ 'key', 'connector-factory', @@ -50,6 +51,7 @@ const managementApiIdentifiableEntityNames = Object.freeze([ 'saml-application', 'secret', 'email-template', + 'one-time-token', ]); /** Additional tags that cannot be inferred from the path. */ diff --git a/packages/integration-tests/src/api/one-time-token.ts b/packages/integration-tests/src/api/one-time-token.ts index 4940666cb..d3c94c9a7 100644 --- a/packages/integration-tests/src/api/one-time-token.ts +++ b/packages/integration-tests/src/api/one-time-token.ts @@ -1,4 +1,4 @@ -import { type OneTimeToken } from '@logto/schemas'; +import { type OneTimeTokenStatus, type OneTimeToken } from '@logto/schemas'; import { authedAdminApi } from './api.js'; @@ -20,3 +20,13 @@ export const verifyOneTimeToken = async ( json: verifyOneTimeToken, }) .json(); + +export const getOneTimeTokenById = async (id: string) => + authedAdminApi.get(`one-time-tokens/${id}`).json(); + +export const updateOneTimeTokenStatus = async (id: string, status: OneTimeTokenStatus) => + authedAdminApi + .put(`one-time-tokens/${id}/status`, { + json: { status }, + }) + .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 d933ad268..10ce0a6f2 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 @@ -1,14 +1,19 @@ import { OneTimeTokenStatus } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; -import { createOneTimeToken, verifyOneTimeToken } from '#src/api/one-time-token.js'; +import { + createOneTimeToken, + verifyOneTimeToken, + getOneTimeTokenById, + updateOneTimeTokenStatus, +} from '#src/api/one-time-token.js'; import { expectRejects } from '#src/helpers/index.js'; -import { devFeatureTest } from '#src/utils.js'; +import { devFeatureTest, waitFor } from '#src/utils.js'; const { it, describe } = devFeatureTest; -describe('one time tokens API', () => { - it('should create one time token with default 10 mins expiration time', async () => { +describe('one-time tokens API', () => { + it('should create one-time token with default 10 mins expiration time', async () => { const email = `foo${generateStandardId()}@bar.com`; const oneTimeToken = await createOneTimeToken({ email, @@ -22,7 +27,7 @@ describe('one time tokens API', () => { expect(oneTimeToken.token.length).toBe(32); }); - it('should create one time token with custom expiration time', async () => { + it('should create one-time token with custom expiration time', async () => { const email = `foo${generateStandardId()}@bar.com`; const oneTimeToken = await createOneTimeToken({ email, @@ -36,7 +41,7 @@ describe('one time tokens API', () => { expect(oneTimeToken.token.length).toBe(32); }); - it('should create one time token with `applicationIds` and `jitOrganizationIds` configured', async () => { + it('should create one-time token with `applicationIds` and `jitOrganizationIds` configured', async () => { const email = `foo${generateStandardId()}@bar.com`; const oneTimeToken = await createOneTimeToken({ email, @@ -53,10 +58,41 @@ describe('one time tokens API', () => { expect(oneTimeToken.token.length).toBe(32); }); - // eslint-disable-next-line @typescript-eslint/no-empty-function - it(`update expired tokens' status to expired when creating new one time token`, async () => {}); + it('should be able to get one-time token by its ID', async () => { + const email = `foo${generateStandardId()}@bar.com`; + const oneTimeToken = await createOneTimeToken({ + email, + context: { + jitOrganizationIds: ['org-1'], + }, + }); - it('should verify one time token', async () => { + const token = await getOneTimeTokenById(oneTimeToken.id); + expect(token).toEqual(oneTimeToken); + }); + + it('should throw when getting a non-existent one-time token', async () => { + await expectRejects(getOneTimeTokenById('non-existent-id'), { + code: 'entity.not_exists_with_id', + status: 404, + }); + }); + + it(`update expired tokens' status to expired when creating new one-time token`, async () => { + const email = `foo${generateStandardId()}@bar.com`; + const oneTimeToken = await createOneTimeToken({ + email, + expiresIn: 1, + }); + + await waitFor(1001); + await createOneTimeToken({ email }); + + const reFetchedToken = await getOneTimeTokenById(oneTimeToken.id); + expect(reFetchedToken.status).toBe(OneTimeTokenStatus.Expired); + }); + + it('should verify one-time token', async () => { const email = `foo${generateStandardId()}@bar.com`; const oneTimeToken = await createOneTimeToken({ email, @@ -88,7 +124,7 @@ describe('one time tokens API', () => { ); }); - it('should not succeed to verify one time token with expired token', async () => { + it('should not succeed to verify one-time token with expired token', async () => { const email = `foo${generateStandardId()}@bar.com`; const oneTimeToken = await createOneTimeToken({ email, @@ -99,9 +135,7 @@ describe('one time tokens API', () => { }); // Wait for the token to be expired - await new Promise((resolve) => { - setTimeout(resolve, 2000); - }); + await waitFor(1001); await expectRejects( verifyOneTimeToken({ @@ -115,7 +149,7 @@ describe('one time tokens API', () => { ); }); - it('should not succeed to verify one time token wrong email', async () => { + it('should not succeed to verify one-time token wrong email', async () => { const email = `foo${generateStandardId()}@bar.com`; const oneTimeToken = await createOneTimeToken({ email, @@ -136,7 +170,7 @@ describe('one time tokens API', () => { ); }); - it('should not succeed to verify one time token wrong token', async () => { + it('should not succeed to verify one-time token wrong token', async () => { const email = `foo${generateStandardId()}@bar.com`; await createOneTimeToken({ email, @@ -157,9 +191,62 @@ describe('one time tokens API', () => { ); }); - // eslint-disable-next-line @typescript-eslint/no-empty-function - it('should throw token_expired error and update token status to expired (token already expired but status is not updated)', async () => {}); + it('should throw token_expired error and update token status to expired (token already expired but status is not updated)', async () => { + const email = `foo${generateStandardId()}@bar.com`; + const oneTimeToken = await createOneTimeToken({ email, expiresIn: 1 }); - // eslint-disable-next-line @typescript-eslint/no-empty-function - it('should throw token_revoked error', async () => {}); + await waitFor(1001); + + await expectRejects( + verifyOneTimeToken({ + email, + token: oneTimeToken.token, + }), + { + code: 'one_time_token.token_expired', + status: 400, + } + ); + + const updatedToken = await getOneTimeTokenById(oneTimeToken.id); + expect(updatedToken.status).toBe(OneTimeTokenStatus.Expired); + }); + + it('should be able to revoke a token by updating the status', async () => { + const email = `foo${generateStandardId()}@bar.com`; + const oneTimeToken = await createOneTimeToken({ email, expiresIn: 1 }); + + await updateOneTimeTokenStatus(oneTimeToken.id, OneTimeTokenStatus.Revoked); + + const updatedToken = await getOneTimeTokenById(oneTimeToken.id); + expect(updatedToken.status).toBe(OneTimeTokenStatus.Revoked); + }); + + it('should throw when trying to re-activate a token', async () => { + const email = `foo${generateStandardId()}@bar.com`; + const oneTimeToken = await createOneTimeToken({ email, expiresIn: 1 }); + + await expectRejects(updateOneTimeTokenStatus(oneTimeToken.id, OneTimeTokenStatus.Active), { + code: 'one_time_token.cannot_reactivate_token', + status: 400, + }); + }); + + it('should throw when verifying a revoked token', async () => { + const email = `foo${generateStandardId()}@bar.com`; + const oneTimeToken = await createOneTimeToken({ email }); + + await updateOneTimeTokenStatus(oneTimeToken.id, OneTimeTokenStatus.Revoked); + + await expectRejects( + verifyOneTimeToken({ + email, + token: oneTimeToken.token, + }), + { + code: 'one_time_token.token_revoked', + status: 400, + } + ); + }); });