0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): update password in profile (#6571)

This commit is contained in:
wangsijie 2024-09-24 14:56:04 +08:00 committed by GitHub
parent 657236a492
commit 1b001f8fe8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 507 additions and 50 deletions

View file

@ -0,0 +1,43 @@
import { expirationTime } from '../queries/verification-records.js';
import {
buildVerificationRecord,
verificationRecordDataGuard,
} from '../routes/experience/classes/verifications/index.js';
import { type VerificationRecord } from '../routes/experience/classes/verifications/verification-record.js';
import type Libraries from '../tenants/Libraries.js';
import type Queries from '../tenants/Queries.js';
import assertThat from '../utils/assert-that.js';
export const buildUserVerificationRecordById = async (
userId: string,
id: string,
queries: Queries,
libraries: Libraries
) => {
const record = await queries.verificationRecords.findUserActiveVerificationRecordById(userId, id);
assertThat(record, 'verification_record.not_found');
const result = verificationRecordDataGuard.safeParse({
...record.data,
id: record.id,
});
assertThat(result.success, 'verification_record.not_found');
return buildVerificationRecord(libraries, queries, result.data);
};
export const saveVerificationRecord = async (
userId: string,
verificationRecord: VerificationRecord,
queries: Queries
) => {
const { id, ...rest } = verificationRecord.toJson();
return queries.verificationRecords.upsertRecord({
id,
userId,
data: rest,
expiresAt: new Date(Date.now() + expirationTime).valueOf(),
});
};

View file

@ -0,0 +1,42 @@
import { type VerificationRecord, VerificationRecords } from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { convertToIdentifiers } from '#src/utils/sql.js';
import { buildFindEntityByIdWithPool } from '../database/find-entity-by-id.js';
import { buildUpdateWhereWithPool } from '../database/update-where.js';
const { table, fields } = convertToIdentifiers(VerificationRecords);
// Default expiration time for verification records is 10 minutes
// TODO: Remove this after we implement "Account Center" configuration
export const expirationTime = 1000 * 60 * 10;
export class VerificationRecordQueries {
public readonly insert = buildInsertIntoWithPool(this.pool)(VerificationRecords, {
returning: true,
});
public upsertRecord = buildInsertIntoWithPool(this.pool)(VerificationRecords, {
onConflict: {
fields: [fields.id],
setExcludedFields: [fields.expiresAt],
},
});
public readonly update = buildUpdateWhereWithPool(this.pool)(VerificationRecords, true);
public readonly find = buildFindEntityByIdWithPool(this.pool)(VerificationRecords);
constructor(public readonly pool: CommonQueryMethods) {}
public findUserActiveVerificationRecordById = async (userId: string, id: string) => {
return this.pool.maybeOne<VerificationRecord>(sql`
select * from ${table}
where ${fields.userId} = ${userId}
and ${fields.id} = ${id}
and ${fields.expiresAt} > now()
`);
};
}

View file

@ -2,7 +2,7 @@ import {
SentinelActionResult,
SentinelActivityTargetType,
SentinelDecision,
type InteractionIdentifier,
type VerificationIdentifier,
type Sentinel,
type SentinelActivityAction,
} from '@logto/schemas';
@ -33,7 +33,7 @@ export async function withSentinel<T>(
}: {
sentinel: Sentinel;
action: SentinelActivityAction;
identifier: InteractionIdentifier;
identifier: VerificationIdentifier;
payload: Record<string, unknown>;
},
verificationPromise: Promise<T>

View file

@ -1,9 +1,11 @@
import {
SignInIdentifier,
type VerificationIdentifier,
VerificationType,
type InteractionIdentifier,
type User,
type VerificationCodeSignInIdentifier,
AdditionalIdentifier,
} from '@logto/schemas';
import type Queries from '#src/tenants/Queries.js';
@ -12,7 +14,7 @@ import type { InteractionProfile } from '../types.js';
export const findUserByIdentifier = async (
userQuery: Queries['users'],
{ type, value }: InteractionIdentifier
{ type, value }: VerificationIdentifier
) => {
switch (type) {
case SignInIdentifier.Username: {
@ -24,6 +26,9 @@ export const findUserByIdentifier = async (
case SignInIdentifier.Phone: {
return userQuery.findUserByPhone(value);
}
case AdditionalIdentifier.UserId: {
return userQuery.findUserById(value);
}
}
};

View file

@ -1,8 +1,8 @@
import {
type VerificationIdentifier,
VerificationType,
interactionIdentifierGuard,
type InteractionIdentifier,
type User,
verificationIdentifierGuard,
} from '@logto/schemas';
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
import { generateStandardId } from '@logto/shared';
@ -20,14 +20,14 @@ import { type IdentifierVerificationRecord } from './verification-record.js';
export type PasswordVerificationRecordData = {
id: string;
type: VerificationType.Password;
identifier: InteractionIdentifier;
identifier: VerificationIdentifier;
verified: boolean;
};
export const passwordVerificationRecordDataGuard = z.object({
id: z.string(),
type: z.literal(VerificationType.Password),
identifier: interactionIdentifierGuard,
identifier: verificationIdentifierGuard,
verified: z.boolean(),
}) satisfies ToZodObject<PasswordVerificationRecordData>;
@ -35,7 +35,7 @@ export class PasswordVerification
implements IdentifierVerificationRecord<VerificationType.Password>
{
/** Factory method to create a new `PasswordVerification` record using an identifier */
static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) {
static create(libraries: Libraries, queries: Queries, identifier: VerificationIdentifier) {
return new PasswordVerification(libraries, queries, {
id: generateStandardId(),
type: VerificationType.Password,
@ -45,7 +45,7 @@ export class PasswordVerification
}
readonly type = VerificationType.Password;
readonly identifier: InteractionIdentifier;
readonly identifier: VerificationIdentifier;
readonly id: string;
private verified: boolean;

View file

@ -43,8 +43,9 @@ import statusRoutes from './status.js';
import subjectTokenRoutes from './subject-token.js';
import swaggerRoutes from './swagger/index.js';
import systemRoutes from './system.js';
import type { AnonymousRouter, ManagementApiRouter, ProfileRouter } from './types.js';
import type { AnonymousRouter, ManagementApiRouter, UserRouter } from './types.js';
import userAssetsRoutes from './user-assets.js';
import verificationRoutes from './verification/index.js';
import verificationCodeRoutes from './verification-code.js';
import wellKnownRoutes from './well-known/index.js';
import wellKnownOpenApiRoutes from './well-known/well-known.openapi.js';
@ -97,30 +98,31 @@ const createRouters = (tenant: TenantContext) => {
const anonymousRouter: AnonymousRouter = new Router();
wellKnownRoutes(anonymousRouter, tenant);
wellKnownOpenApiRoutes(anonymousRouter, {
experienceRouters: [experienceRouter, interactionRouter],
managementRouters: [managementRouter, anonymousRouter],
});
const userRouter: UserRouter = new Router();
profileRoutes(userRouter, tenant);
verificationRoutes(userRouter, tenant);
wellKnownRoutes(anonymousRouter, tenant);
statusRoutes(anonymousRouter, tenant);
authnRoutes(anonymousRouter, tenant);
if (EnvSet.values.isDevFeaturesEnabled) {
const profileRouter: ProfileRouter = new Router();
profileRoutes(profileRouter, tenant);
}
wellKnownOpenApiRoutes(anonymousRouter, {
experienceRouters: [experienceRouter, interactionRouter],
managementRouters: [managementRouter, anonymousRouter],
userRouters: [userRouter],
});
// The swagger.json should contain all API routers.
swaggerRoutes(anonymousRouter, [
managementRouter,
anonymousRouter,
experienceRouter,
userRouter,
// TODO: interactionRouter should be removed from swagger.json
interactionRouter,
]);
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter];
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter, userRouter];
};
export default function initApis(tenant: TenantContext): Koa {

View file

@ -0,0 +1,44 @@
{
"tags": [
{
"name": "Profile",
"description": "Profile routes provide functionality for managing user profiles for the end user to interact directly with access tokens."
},
{
"name": "Dev feature"
}
],
"paths": {
"/api/profile/password": {
"post": {
"operationId": "UpdatePassword",
"summary": "Update password",
"description": "Update password for the user, a verification record is required.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"password": {
"description": "The new password for the user."
},
"verificationRecordId": {
"description": "The verification record ID."
}
}
}
}
}
},
"responses": {
"204": {
"description": "The password was updated successfully."
},
"400": {
"description": "The verification record is invalid."
}
}
}
}
}
}

View file

@ -2,32 +2,51 @@ import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import { EnvSet } from '../../env-set/index.js';
import { encryptUserPassword } from '../../libraries/user.utils.js';
import { buildUserVerificationRecordById } from '../../libraries/verification.js';
import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js';
import type { ProfileRouter, RouterInitArgs } from '../types.js';
import assertThat from '../../utils/assert-that.js';
import type { UserRouter, RouterInitArgs } from '../types.js';
/**
* Authn stands for authentication.
* This router will have a route `/authn` to authenticate tokens with a general manner.
*/
export default function profileRoutes<T extends ProfileRouter>(
...[router, { provider }]: RouterInitArgs<T>
export default function profileRoutes<T extends UserRouter>(
...[router, { provider, queries, libraries }]: RouterInitArgs<T>
) {
const {
users: { updateUserById },
} = queries;
router.use(koaOidcAuth(provider));
// TODO: test route only, will implement a better one later
router.get(
'/profile',
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
}
router.post(
'/profile/password',
koaGuard({
response: z.object({
sub: z.string(),
}),
status: [200],
body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }),
status: [204, 400],
}),
async (ctx, next) => {
ctx.body = {
sub: ctx.auth.id,
};
ctx.status = 200;
const { id: userId } = ctx.auth;
const { password, verificationRecordId } = ctx.guard.body;
// TODO(LOG-9947): apply password policy
// TODO(LOG-10005): trigger user updated webhook
const verificationRecord = await buildUserVerificationRecordById(
userId,
verificationRecordId,
queries,
libraries
);
assertThat(verificationRecord.isVerified, 'verification_record.not_found');
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
ctx.status = 204;
return next();
}

View file

@ -57,3 +57,8 @@ curl --location \\
\`\`\`
Replace \`[tenant-id]\` with your Logto tenant ID and \`eyJhbG...2g\` with the access token you fetched earlier.`;
export const userApiAuthDescription = `Logto User API is a set of REST APIs that gives the end user the ability to manage their own profile and perform verifications.
To use this API, you need to have an openid access token with empty audience and required scopes.
`;

View file

@ -13,7 +13,7 @@ import assertThat from '#src/utils/assert-that.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
import { translationSchemas } from '#src/utils/zod.js';
import { managementApiAuthDescription } from '../consts.js';
import { managementApiAuthDescription, userApiAuthDescription } from '../consts.js';
import {
type FindSupplementFilesOptions,
@ -152,6 +152,60 @@ export const buildExperienceApiBaseDocument = (
tags: [...tags].map((tag) => ({ name: tag })),
});
const userApiIdentifiableEntityNames = Object.freeze(['profile', 'verification']);
export const buildUserApiBaseDocument = (
pathMap: Map<string, OpenAPIV3.PathItemObject>,
tags: Set<string>,
origin: string
): OpenAPIV3.Document => ({
openapi: '3.0.1',
servers: [
{
url: EnvSet.values.isCloud ? 'https://[tenant_id].logto.app/' : origin,
description: 'Logto endpoint address.',
},
],
info: {
title: 'Logto user API references',
description:
'API references for Logto user interaction.' +
condString(
EnvSet.values.isCloud &&
'\n\nNote: The documentation is for Logto Cloud. If you are using Logto OSS, please refer to the response of `/api/swagger.json` endpoint on your Logto instance.'
),
version: 'Cloud',
},
paths: Object.fromEntries(pathMap),
security: [{ cookieAuth: ['all'] }],
components: {
schemas: translationSchemas,
securitySchemes: {
OAuth2: {
type: 'oauth2',
description: userApiAuthDescription,
flows: {
clientCredentials: {
tokenUrl: '/oidc/token',
scopes: {
openid: 'OpenID scope',
profile: 'Profile scope',
},
},
},
},
},
parameters: userApiIdentifiableEntityNames.reduce(
(previous, entityName) => ({
...previous,
...buildPathIdParameters(entityName),
}),
{}
),
},
tags: [...tags].map((tag) => ({ name: tag })),
});
export const getSupplementDocuments = async (
directory = 'routes',
option?: FindSupplementFilesOptions

View file

@ -24,10 +24,7 @@ const methodToVerb = Object.freeze({
type RouteDictionary = Record<`${OpenAPIV3.HttpMethods} ${string}`, string>;
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({
// Subject tokens
'post /subject-tokens': 'CreateSubjectToken',
});
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({});
export const customRoutes: Readonly<RouteDictionary> = Object.freeze({
// Authn
@ -127,6 +124,8 @@ const exceptionPrefixes = Object.freeze([
'/interaction',
'/experience',
'/sign-in-exp/default/check-password',
'/profile',
'/verifications',
]);
const isPathParameter = (segment?: string) =>

View file

@ -17,7 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext &
export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;
export type ProfileRouter = Router<unknown, WithAuthContext & WithLogContext & WithI18nContext>;
export type UserRouter = Router<unknown, ManagementApiRouterContext>;
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;

View file

@ -0,0 +1,41 @@
{
"tags": [
{
"name": "Verifications",
"description": "Endpoints for creating and validating verification records, which can be used in Profile routes."
},
{
"name": "Dev feature"
}
],
"paths": {
"/api/verifications/password": {
"post": {
"operationId": "CreateVerificationByPassword",
"summary": "Create a record by password",
"description": "Create a verification record by verifying the password.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"password": {
"description": "The password of the user."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The verification record was created successfully."
},
"422": {
"description": "The password is invalid."
}
}
}
}
}
}

View file

@ -0,0 +1,60 @@
import { AdditionalIdentifier, SentinelActivityAction } from '@logto/schemas';
import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import { EnvSet } from '../../env-set/index.js';
import { saveVerificationRecord } from '../../libraries/verification.js';
import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js';
import { withSentinel } from '../experience/classes/libraries/sentinel-guard.js';
import { PasswordVerification } from '../experience/classes/verifications/password-verification.js';
import type { UserRouter, RouterInitArgs } from '../types.js';
export default function verificationRoutes<T extends UserRouter>(
...[router, { provider, queries, libraries, sentinel }]: RouterInitArgs<T>
) {
router.use(koaOidcAuth(provider));
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
}
router.post(
'/verifications/password',
koaGuard({
body: z.object({ password: z.string().min(1) }),
response: z.object({ verificationRecordId: z.string() }),
status: [201, 422],
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
const { password } = ctx.guard.body;
const passwordVerification = PasswordVerification.create(libraries, queries, {
type: AdditionalIdentifier.UserId,
value: userId,
});
await withSentinel(
{
sentinel,
action: SentinelActivityAction.Password,
identifier: {
type: AdditionalIdentifier.UserId,
value: userId,
},
payload: {
verificationId: passwordVerification.id,
},
},
passwordVerification.verify(password)
);
await saveVerificationRecord(userId, passwordVerification, queries);
ctx.body = { verificationRecordId: passwordVerification.id };
ctx.status = 201;
return next();
}
);
}

View file

@ -68,6 +68,17 @@
}
}
}
},
"/api/.well-known/user.openapi.json": {
"get": {
"summary": "Get User API swagger JSON",
"description": "The endpoint for the User API JSON document. The JSON conforms to the [OpenAPI v3.0.1](https://spec.openapis.org/oas/v3.0.1) (a.k.a. Swagger) specification.",
"responses": {
"200": {
"description": "The JSON document."
}
}
}
}
}
}

View file

@ -3,6 +3,7 @@ import {
buildManagementApiBaseDocument,
getSupplementDocuments,
assembleSwaggerDocument,
buildUserApiBaseDocument,
} from '#src/routes/swagger/utils/documents.js';
import {
buildRouterObjects,
@ -14,11 +15,12 @@ import { type AnonymousRouter } from '#src/routes/types.js';
type OpenApiRouters<R> = {
managementRouters: R[];
experienceRouters: R[];
userRouters: R[];
};
export default function openapiRoutes<T extends AnonymousRouter, R extends UnknownRouter>(
router: T,
{ managementRouters, experienceRouters }: OpenApiRouters<R>
{ managementRouters, experienceRouters, userRouters }: OpenApiRouters<R>
) {
router.get('/.well-known/management.openapi.json', async (ctx, next) => {
const managementApiRoutes = buildRouterObjects(managementRouters, {
@ -29,7 +31,7 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
// Find supplemental documents
const supplementDocuments = await getSupplementDocuments('routes', {
excludeDirectories: ['experience', 'interaction'],
excludeDirectories: ['experience', 'interaction', 'profile', 'verification'],
});
const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin);
@ -62,4 +64,24 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
return next();
});
router.get('/.well-known/user.openapi.json', async (ctx, next) => {
const userApiRoutes = buildRouterObjects(userRouters);
const { pathMap, tags } = groupRoutesByPath(userApiRoutes);
// Find supplemental documents
const supplementDocuments = await getSupplementDocuments('routes', {
includeDirectories: ['profile', 'verification'],
});
const baseDocument = buildUserApiBaseDocument(pathMap, tags, ctx.request.origin);
const data = assembleSwaggerDocument(supplementDocuments, baseDocument, ctx);
ctx.body = {
...data,
tags: data.tags?.slice().sort((tagA, tagB) => tagA.name.localeCompare(tagB.name)),
};
return next();
});
}

View file

@ -30,6 +30,7 @@ import { createUsersRolesQueries } from '#src/queries/users-roles.js';
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js';
import { VerificationRecordQueries } from '../queries/verification-records.js';
export default class Queries {
applications = createApplicationQueries(this.pool);
@ -59,6 +60,7 @@ export default class Queries {
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
subjectTokens = createSubjectTokenQueries(this.pool);
personalAccessTokens = new PersonalAccessTokensQueries(this.pool);
verificationRecords = new VerificationRecordQueries(this.pool);
tenants = createTenantQueries(this.pool);
constructor(

View file

@ -0,0 +1,7 @@
import { type KyInstance } from 'ky';
export const updatePassword = async (
api: KyInstance,
verificationRecordId: string,
password: string
) => api.post('profile/password', { json: { password, verificationRecordId } });

View file

@ -0,0 +1,13 @@
import { type KyInstance } from 'ky';
export const createVerificationRecordByPassword = async (api: KyInstance, password: string) => {
const { verificationRecordId } = await api
.post('verifications/password', {
json: {
password,
},
})
.json<{ verificationRecordId: string }>();
return verificationRecordId;
};

View file

@ -29,7 +29,7 @@ import {
export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId);
export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me');
const createUserWithRoles = async (roleNames: string[]) => {
export const createUserWithPassword = async () => {
const username = generateUsername();
const password = generatePassword();
const user = await api
@ -38,6 +38,12 @@ const createUserWithRoles = async (roleNames: string[]) => {
})
.json<User>();
return { user, username, password };
};
const createUserWithRoles = async (roleNames: string[]) => {
const { user, username, password } = await createUserWithPassword();
// Should have roles for default tenant Management API and admin tenant Me API
const roles = await api.get('roles').json<Role[]>();
await Promise.all(
@ -108,6 +114,16 @@ export const initClientAndSignIn = async (
return client;
};
export const signInAndGetProfileApi = async (username: string, password: string) => {
const client = await initClientAndSignIn(username, password);
const accessToken = await client.getAccessToken();
return api.extend({
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
};
export const createUserWithAllRolesAndSignInToClient = async () => {
const [{ id }, { username, password }] = await createUserWithAllRoles();
const client = await initClientAndSignIn(username, password, {

View file

@ -23,6 +23,7 @@ const identifiersTypeToUserProfile = Object.freeze({
username: 'username',
email: 'primaryEmail',
phone: 'primaryPhone',
userId: '',
});
describe('sign-in with password verification happy path', () => {
@ -31,7 +32,7 @@ describe('sign-in with password verification happy path', () => {
await Promise.all([setEmailConnector(), setSmsConnector()]);
});
it.each(Object.values(SignInIdentifier))(
it.each([SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone])(
'should sign-in with password using %p',
async (identifier) => {
const { userProfile, user } = await generateNewUser({

View file

@ -8,10 +8,11 @@ const identifiersTypeToUserProfile = Object.freeze({
username: 'username',
email: 'primaryEmail',
phone: 'primaryPhone',
userId: '',
});
describe('password verifications', () => {
it.each(Object.values(SignInIdentifier))(
it.each([SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone])(
'should verify with password successfully using %p',
async (identifier) => {
const { userProfile, user } = await generateNewUser({

View file

@ -0,0 +1,48 @@
import { updatePassword } from '#src/api/profile.js';
import { createVerificationRecordByPassword } from '#src/api/verification-record.js';
import {
createUserWithPassword,
deleteUser,
initClientAndSignIn,
signInAndGetProfileApi,
} from '#src/helpers/admin-tenant.js';
import { expectRejects } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { devFeatureTest, generatePassword } from '#src/utils.js';
const { describe, it } = devFeatureTest;
describe('profile', () => {
beforeAll(async () => {
await enableAllPasswordSignInMethods();
});
describe('POST /profile/password', () => {
it('should fail if verification record is invalid', async () => {
const { user, username, password } = await createUserWithPassword();
const api = await signInAndGetProfileApi(username, password);
const newPassword = generatePassword();
await expectRejects(updatePassword(api, 'invalid-varification-record-id', newPassword), {
code: 'verification_record.not_found',
status: 400,
});
await deleteUser(user.id);
});
it('should be able to update password', async () => {
const { user, username, password } = await createUserWithPassword();
const api = await signInAndGetProfileApi(username, password);
const verificationRecordId = await createVerificationRecordByPassword(api, password);
const newPassword = generatePassword();
await updatePassword(api, verificationRecordId, newPassword);
// Sign in with new password
await initClientAndSignIn(username, newPassword);
await deleteUser(user.id);
});
});
});

View file

@ -7,7 +7,7 @@ import { adminTenantApi } from '#src/api/api.js';
const { default: OpenApiSchemaValidator } = Validator;
describe('.well-known openapi.json endpoints', () => {
it.each(['management', 'experience'])('should return %s.openapi.json', async (type) => {
it.each(['management', 'experience', 'user'])('should return %s.openapi.json', async (type) => {
const response = await adminTenantApi.get(`.well-known/${type}.openapi.json`);
expect(response).toHaveProperty('status', 200);

View file

@ -23,6 +23,7 @@ import subscription from './subscription.js';
import swagger from './swagger.js';
import user from './user.js';
import verification_code from './verification-code.js';
import verification_record from './verification-record.js';
const errors = {
request,
@ -50,6 +51,7 @@ const errors = {
application,
organization,
single_sign_on,
verification_record,
};
export default Object.freeze(errors);

View file

@ -0,0 +1,5 @@
const verification_record = {
not_found: 'Verification record not found.',
};
export default Object.freeze(verification_record);

View file

@ -49,6 +49,10 @@ export enum SignInIdentifier {
export const signInIdentifierGuard = z.nativeEnum(SignInIdentifier);
export enum AdditionalIdentifier {
UserId = 'userId',
}
export const signUpGuard = z.object({
identifiers: z.nativeEnum(SignInIdentifier).array(),
password: z.boolean(),

View file

@ -2,6 +2,7 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import { z } from 'zod';
import {
AdditionalIdentifier,
MfaFactor,
SignInIdentifier,
jsonObjectGuard,
@ -28,6 +29,16 @@ export enum InteractionEvent {
ForgotPassword = 'ForgotPassword',
}
export type VerificationIdentifier = {
type: SignInIdentifier | AdditionalIdentifier;
value: string;
};
export const verificationIdentifierGuard = z.object({
type: z.union([z.nativeEnum(SignInIdentifier), z.nativeEnum(AdditionalIdentifier)]),
value: z.string(),
}) satisfies ToZodObject<VerificationIdentifier>;
// ====== Experience API payload guards and type definitions start ======
/** Identifiers that can be used to uniquely identify a user. */