mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
Merge pull request #7236 from logto-io/charles-log-10871-core-get-one-time-tokensid
feat(core): add management API to fetch one-time token by ID
This commit is contained in:
commit
4e6e1095f6
7 changed files with 225 additions and 34 deletions
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
router,
|
||||
{
|
||||
queries: {
|
||||
oneTimeTokens: { insertOneTimeToken, updateExpiredOneTimeTokensStatusByEmail },
|
||||
oneTimeTokens: {
|
||||
insertOneTimeToken,
|
||||
updateExpiredOneTimeTokensStatusByEmail,
|
||||
getOneTimeTokenById,
|
||||
},
|
||||
},
|
||||
libraries: {
|
||||
oneTimeTokens: { verifyOneTimeToken },
|
||||
oneTimeTokens: { verifyOneTimeToken, updateOneTimeTokenStatusById },
|
||||
},
|
||||
},
|
||||
]: RouterInitArgs<T>
|
||||
) {
|
||||
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<T extends ManagementApiRouter>(
|
|||
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<T extends ManagementApiRouter>(
|
|||
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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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<OneTimeToken>();
|
||||
|
||||
export const getOneTimeTokenById = async (id: string) =>
|
||||
authedAdminApi.get(`one-time-tokens/${id}`).json<OneTimeToken>();
|
||||
|
||||
export const updateOneTimeTokenStatus = async (id: string, status: OneTimeTokenStatus) =>
|
||||
authedAdminApi
|
||||
.put(`one-time-tokens/${id}/status`, {
|
||||
json: { status },
|
||||
})
|
||||
.json<OneTimeToken>();
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue