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:
parent
21ec39ee13
commit
ee06c2e015
9 changed files with 213 additions and 1 deletions
28
packages/core/src/queries/one-time-tokens.ts
Normal file
28
packages/core/src/queries/one-time-tokens.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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();
|
||||
|
||||
|
|
43
packages/core/src/routes/one-time-tokens.openapi.json
Normal file
43
packages/core/src/routes/one-time-tokens.openapi.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
62
packages/core/src/routes/one-time-tokens.ts
Normal file
62
packages/core/src/routes/one-time-tokens.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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'
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
13
packages/integration-tests/src/api/one-time-token.ts
Normal file
13
packages/integration-tests/src/api/one-time-token.ts
Normal 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>();
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue