mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -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 interactionRoutes from './interaction/index.js';
|
||||||
import logRoutes from './log.js';
|
import logRoutes from './log.js';
|
||||||
import logtoConfigRoutes from './logto-config/index.js';
|
import logtoConfigRoutes from './logto-config/index.js';
|
||||||
|
import oneTimeTokenRoutes from './one-time-tokens.js';
|
||||||
import organizationRoutes from './organization/index.js';
|
import organizationRoutes from './organization/index.js';
|
||||||
import resourceRoutes from './resource.js';
|
import resourceRoutes from './resource.js';
|
||||||
import resourceScopeRoutes from './resource.scope.js';
|
import resourceScopeRoutes from './resource.scope.js';
|
||||||
|
@ -104,6 +105,9 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
accountCentersRoutes(managementRouter, tenant);
|
accountCentersRoutes(managementRouter, tenant);
|
||||||
samlApplicationRoutes(managementRouter, tenant);
|
samlApplicationRoutes(managementRouter, tenant);
|
||||||
emailTemplateRoutes(managementRouter, tenant);
|
emailTemplateRoutes(managementRouter, tenant);
|
||||||
|
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||||
|
oneTimeTokenRoutes(managementRouter, tenant);
|
||||||
|
}
|
||||||
|
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
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',
|
'saml-application',
|
||||||
'secret',
|
'secret',
|
||||||
'email-template',
|
'email-template',
|
||||||
|
'one-time-token',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Additional tags that cannot be inferred from the path. */
|
/** Additional tags that cannot be inferred from the path. */
|
||||||
|
@ -59,7 +60,8 @@ const additionalTags = Object.freeze(
|
||||||
'Custom UI assets',
|
'Custom UI assets',
|
||||||
'Organization users',
|
'Organization users',
|
||||||
'SAML applications',
|
'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'],
|
['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
|
* 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,
|
* 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 { createLogQueries } from '#src/queries/log.js';
|
||||||
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
||||||
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.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 OrganizationQueries from '#src/queries/organization/index.js';
|
||||||
import { createPasscodeQueries } from '#src/queries/passcode.js';
|
import { createPasscodeQueries } from '#src/queries/passcode.js';
|
||||||
import { createResourceQueries } from '#src/queries/resource.js';
|
import { createResourceQueries } from '#src/queries/resource.js';
|
||||||
|
@ -58,6 +59,7 @@ export default class Queries {
|
||||||
applicationsRoles = createApplicationsRolesQueries(this.pool);
|
applicationsRoles = createApplicationsRolesQueries(this.pool);
|
||||||
verificationStatuses = createVerificationStatusQueries(this.pool);
|
verificationStatuses = createVerificationStatusQueries(this.pool);
|
||||||
hooks = createHooksQueries(this.pool);
|
hooks = createHooksQueries(this.pool);
|
||||||
|
oneTimeTokens = createOneTimeTokenQueries(this.pool);
|
||||||
domains = createDomainsQueries(this.pool);
|
domains = createDomainsQueries(this.pool);
|
||||||
dailyActiveUsers = createDailyActiveUsersQueries(this.pool);
|
dailyActiveUsers = createDailyActiveUsersQueries(this.pool);
|
||||||
dailyTokenUsage = createDailyTokenUsageQueries(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