0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat: add POST /one-time-tokens API (#7090)

* feat: add POST /one-time-tokens API

* chore: update code
This commit is contained in:
Darcy Ye 2025-03-06 02:59:51 -08:00 committed by GitHub
parent 21ec39ee13
commit ee06c2e015
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 213 additions and 1 deletions

View file

@ -0,0 +1,28 @@
import { OneTimeTokens, OneTimeTokenStatus } from '@logto/schemas';
import type { CommonQueryMethods } from '@silverhand/slonik';
import { sql } from '@silverhand/slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { convertToIdentifiers } from '#src/utils/sql.js';
const { table, fields } = convertToIdentifiers(OneTimeTokens);
export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => {
const insertOneTimeToken = buildInsertIntoWithPool(pool)(OneTimeTokens, {
returning: true,
});
const updateExpiredOneTimeTokensStatusByEmail = async (email: string) =>
pool.query(sql`
update ${table}
set ${fields.status} = ${OneTimeTokenStatus.Expired}
where ${fields.expiresAt} <= now()
and ${fields.status} = ${OneTimeTokenStatus.Active}
and ${fields.email} = ${email}
`);
return {
insertOneTimeToken,
updateExpiredOneTimeTokensStatusByEmail,
};
};

View file

@ -36,6 +36,7 @@ import hookRoutes from './hook.js';
import interactionRoutes from './interaction/index.js';
import logRoutes from './log.js';
import logtoConfigRoutes from './logto-config/index.js';
import oneTimeTokenRoutes from './one-time-tokens.js';
import organizationRoutes from './organization/index.js';
import resourceRoutes from './resource.js';
import resourceScopeRoutes from './resource.scope.js';
@ -104,6 +105,9 @@ const createRouters = (tenant: TenantContext) => {
accountCentersRoutes(managementRouter, tenant);
samlApplicationRoutes(managementRouter, tenant);
emailTemplateRoutes(managementRouter, tenant);
if (EnvSet.values.isDevFeaturesEnabled) {
oneTimeTokenRoutes(managementRouter, tenant);
}
const anonymousRouter: AnonymousRouter = new Router();

View file

@ -0,0 +1,43 @@
{
"tags": [
{
"name": "One-time tokens",
"description": "One-time tokens are used for various authentication and verification purposes. They are typically sent via email and have an expiration time."
},
{
"name": "Dev feature"
}
],
"paths": {
"/api/one-time-tokens": {
"post": {
"summary": "Create one-time token",
"description": "Create a new one-time token associated with an email address. The token can be used for verification purposes and has an expiration time.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"email": {
"description": "The email address to associate with the one-time token."
},
"expiresIn": {
"description": "The expiration time in seconds. If not provided, defaults to 2 days (172,800 seconds)."
},
"context": {
"description": "Additional context to store with the one-time token. This can be used to store arbitrary data that will be associated with the token."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The one-time token was created successfully."
}
}
}
}
}
}

View file

@ -0,0 +1,62 @@
import { OneTimeTokens } from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared';
import { trySafe } from '@silverhand/essentials';
import { addSeconds } from 'date-fns';
import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import type { ManagementApiRouter, RouterInitArgs } from './types.js';
// Default expiration time: 10 minutes.
const defaultExpiresTime = 10 * 60;
export default function oneTimeTokenRoutes<T extends ManagementApiRouter>(
...[
router,
{
queries: {
oneTimeTokens: { insertOneTimeToken, updateExpiredOneTimeTokensStatusByEmail },
},
},
]: RouterInitArgs<T>
) {
router.post(
'/one-time-tokens',
koaGuard({
body: OneTimeTokens.createGuard
.pick({ email: true, context: true })
.partial({
context: true,
})
.merge(
z.object({
// Expiration time in seconds.
expiresIn: z.number().min(1).optional(),
})
),
response: OneTimeTokens.guard,
status: [201],
}),
async (ctx, next) => {
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);
const oneTimeToken = await insertOneTimeToken({
...rest,
id: generateStandardId(),
// TODO: export generate random string with specified length from @logto/shared.
token: generateStandardSecret(),
expiresAt: expiresAt.getTime(),
});
ctx.status = 201;
ctx.body = oneTimeToken;
return next();
}
);
}

View file

@ -50,6 +50,7 @@ const managementApiIdentifiableEntityNames = Object.freeze([
'saml-application',
'secret',
'email-template',
'one-time-token',
]);
/** Additional tags that cannot be inferred from the path. */
@ -59,7 +60,8 @@ const additionalTags = Object.freeze(
'Custom UI assets',
'Organization users',
'SAML applications',
'SAML applications auth flow'
'SAML applications auth flow',
EnvSet.values.isDevFeaturesEnabled && 'One-time tokens'
)
);

View file

@ -39,6 +39,10 @@ const tagMap = new Map([
['saml', 'SAML applications auth flow'],
]);
if (EnvSet.values.isDevFeaturesEnabled) {
tagMap.set('one-time-tokens', 'One-time tokens');
}
/**
* Build a tag name from the given absolute path. The function will get the root component name
* from the path and try to find the mapping in the {@link tagMap}. If the mapping is not found,

View file

@ -14,6 +14,7 @@ import { createHooksQueries } from '#src/queries/hooks.js';
import { createLogQueries } from '#src/queries/log.js';
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js';
import { createOneTimeTokenQueries } from '#src/queries/one-time-tokens.js';
import OrganizationQueries from '#src/queries/organization/index.js';
import { createPasscodeQueries } from '#src/queries/passcode.js';
import { createResourceQueries } from '#src/queries/resource.js';
@ -58,6 +59,7 @@ export default class Queries {
applicationsRoles = createApplicationsRolesQueries(this.pool);
verificationStatuses = createVerificationStatusQueries(this.pool);
hooks = createHooksQueries(this.pool);
oneTimeTokens = createOneTimeTokenQueries(this.pool);
domains = createDomainsQueries(this.pool);
dailyActiveUsers = createDailyActiveUsersQueries(this.pool);
dailyTokenUsage = createDailyTokenUsageQueries(this.pool);

View file

@ -0,0 +1,13 @@
import { type OneTimeToken } from '@logto/schemas';
import { authedAdminApi } from './api.js';
export type CreateOneTimeToken = Pick<OneTimeToken, 'email'> &
Partial<Pick<OneTimeToken, 'context'>> & { expiresIn?: number };
export const createOneTimeToken = async (createOneTimeToken: CreateOneTimeToken) =>
authedAdminApi
.post('one-time-tokens', {
json: createOneTimeToken,
})
.json<OneTimeToken>();

View file

@ -0,0 +1,54 @@
import { OneTimeTokenStatus } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { createOneTimeToken } from '#src/api/one-time-token.js';
import { devFeatureTest } 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 () => {
const email = `foo${generateStandardId()}@bar.com`;
const oneTimeToken = await createOneTimeToken({
email,
});
expect(oneTimeToken.expiresAt).toBeGreaterThan(Date.now());
expect(oneTimeToken.expiresAt).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000);
expect(oneTimeToken.status).toBe(OneTimeTokenStatus.Active);
expect(oneTimeToken.context).toEqual({});
expect(oneTimeToken.email).toBe(email);
expect(oneTimeToken.token.length).toBe(32);
});
it('should create one time token with custom expiration time', async () => {
const email = `foo${generateStandardId()}@bar.com`;
const oneTimeToken = await createOneTimeToken({
email,
expiresIn: 30,
});
expect(oneTimeToken.expiresAt).toBeGreaterThan(Date.now());
expect(oneTimeToken.expiresAt).toBeLessThanOrEqual(Date.now() + 30 * 1000);
expect(oneTimeToken.status).toBe(OneTimeTokenStatus.Active);
expect(oneTimeToken.email).toBe(email);
expect(oneTimeToken.token.length).toBe(32);
});
it('should create one time token with `applicationIds` and `jitOrganizationIds` configured', async () => {
const email = `foo${generateStandardId()}@bar.com`;
const oneTimeToken = await createOneTimeToken({
email,
context: {
jitOrganizationIds: ['org-1'],
},
});
expect(oneTimeToken.status).toBe(OneTimeTokenStatus.Active);
expect(oneTimeToken.email).toBe(email);
expect(oneTimeToken.context).toEqual({
jitOrganizationIds: ['org-1'],
});
expect(oneTimeToken.token.length).toBe(32);
});
});