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:
parent
657236a492
commit
1b001f8fe8
28 changed files with 507 additions and 50 deletions
43
packages/core/src/libraries/verification.ts
Normal file
43
packages/core/src/libraries/verification.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
};
|
42
packages/core/src/queries/verification-records.ts
Normal file
42
packages/core/src/queries/verification-records.ts
Normal 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()
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import {
|
||||||
SentinelActionResult,
|
SentinelActionResult,
|
||||||
SentinelActivityTargetType,
|
SentinelActivityTargetType,
|
||||||
SentinelDecision,
|
SentinelDecision,
|
||||||
type InteractionIdentifier,
|
type VerificationIdentifier,
|
||||||
type Sentinel,
|
type Sentinel,
|
||||||
type SentinelActivityAction,
|
type SentinelActivityAction,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
@ -33,7 +33,7 @@ export async function withSentinel<T>(
|
||||||
}: {
|
}: {
|
||||||
sentinel: Sentinel;
|
sentinel: Sentinel;
|
||||||
action: SentinelActivityAction;
|
action: SentinelActivityAction;
|
||||||
identifier: InteractionIdentifier;
|
identifier: VerificationIdentifier;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
},
|
},
|
||||||
verificationPromise: Promise<T>
|
verificationPromise: Promise<T>
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import {
|
import {
|
||||||
SignInIdentifier,
|
SignInIdentifier,
|
||||||
|
type VerificationIdentifier,
|
||||||
VerificationType,
|
VerificationType,
|
||||||
type InteractionIdentifier,
|
type InteractionIdentifier,
|
||||||
type User,
|
type User,
|
||||||
type VerificationCodeSignInIdentifier,
|
type VerificationCodeSignInIdentifier,
|
||||||
|
AdditionalIdentifier,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
|
@ -12,7 +14,7 @@ import type { InteractionProfile } from '../types.js';
|
||||||
|
|
||||||
export const findUserByIdentifier = async (
|
export const findUserByIdentifier = async (
|
||||||
userQuery: Queries['users'],
|
userQuery: Queries['users'],
|
||||||
{ type, value }: InteractionIdentifier
|
{ type, value }: VerificationIdentifier
|
||||||
) => {
|
) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SignInIdentifier.Username: {
|
case SignInIdentifier.Username: {
|
||||||
|
@ -24,6 +26,9 @@ export const findUserByIdentifier = async (
|
||||||
case SignInIdentifier.Phone: {
|
case SignInIdentifier.Phone: {
|
||||||
return userQuery.findUserByPhone(value);
|
return userQuery.findUserByPhone(value);
|
||||||
}
|
}
|
||||||
|
case AdditionalIdentifier.UserId: {
|
||||||
|
return userQuery.findUserById(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import {
|
import {
|
||||||
|
type VerificationIdentifier,
|
||||||
VerificationType,
|
VerificationType,
|
||||||
interactionIdentifierGuard,
|
|
||||||
type InteractionIdentifier,
|
|
||||||
type User,
|
type User,
|
||||||
|
verificationIdentifierGuard,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
|
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
|
@ -20,14 +20,14 @@ import { type IdentifierVerificationRecord } from './verification-record.js';
|
||||||
export type PasswordVerificationRecordData = {
|
export type PasswordVerificationRecordData = {
|
||||||
id: string;
|
id: string;
|
||||||
type: VerificationType.Password;
|
type: VerificationType.Password;
|
||||||
identifier: InteractionIdentifier;
|
identifier: VerificationIdentifier;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const passwordVerificationRecordDataGuard = z.object({
|
export const passwordVerificationRecordDataGuard = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
type: z.literal(VerificationType.Password),
|
type: z.literal(VerificationType.Password),
|
||||||
identifier: interactionIdentifierGuard,
|
identifier: verificationIdentifierGuard,
|
||||||
verified: z.boolean(),
|
verified: z.boolean(),
|
||||||
}) satisfies ToZodObject<PasswordVerificationRecordData>;
|
}) satisfies ToZodObject<PasswordVerificationRecordData>;
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ export class PasswordVerification
|
||||||
implements IdentifierVerificationRecord<VerificationType.Password>
|
implements IdentifierVerificationRecord<VerificationType.Password>
|
||||||
{
|
{
|
||||||
/** Factory method to create a new `PasswordVerification` record using an identifier */
|
/** 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, {
|
return new PasswordVerification(libraries, queries, {
|
||||||
id: generateStandardId(),
|
id: generateStandardId(),
|
||||||
type: VerificationType.Password,
|
type: VerificationType.Password,
|
||||||
|
@ -45,7 +45,7 @@ export class PasswordVerification
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly type = VerificationType.Password;
|
readonly type = VerificationType.Password;
|
||||||
readonly identifier: InteractionIdentifier;
|
readonly identifier: VerificationIdentifier;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
private verified: boolean;
|
private verified: boolean;
|
||||||
|
|
||||||
|
|
|
@ -43,8 +43,9 @@ import statusRoutes from './status.js';
|
||||||
import subjectTokenRoutes from './subject-token.js';
|
import subjectTokenRoutes from './subject-token.js';
|
||||||
import swaggerRoutes from './swagger/index.js';
|
import swaggerRoutes from './swagger/index.js';
|
||||||
import systemRoutes from './system.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 userAssetsRoutes from './user-assets.js';
|
||||||
|
import verificationRoutes from './verification/index.js';
|
||||||
import verificationCodeRoutes from './verification-code.js';
|
import verificationCodeRoutes from './verification-code.js';
|
||||||
import wellKnownRoutes from './well-known/index.js';
|
import wellKnownRoutes from './well-known/index.js';
|
||||||
import wellKnownOpenApiRoutes from './well-known/well-known.openapi.js';
|
import wellKnownOpenApiRoutes from './well-known/well-known.openapi.js';
|
||||||
|
@ -97,30 +98,31 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
|
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
const anonymousRouter: AnonymousRouter = new Router();
|
||||||
|
|
||||||
wellKnownRoutes(anonymousRouter, tenant);
|
const userRouter: UserRouter = new Router();
|
||||||
wellKnownOpenApiRoutes(anonymousRouter, {
|
profileRoutes(userRouter, tenant);
|
||||||
experienceRouters: [experienceRouter, interactionRouter],
|
verificationRoutes(userRouter, tenant);
|
||||||
managementRouters: [managementRouter, anonymousRouter],
|
|
||||||
});
|
|
||||||
|
|
||||||
|
wellKnownRoutes(anonymousRouter, tenant);
|
||||||
statusRoutes(anonymousRouter, tenant);
|
statusRoutes(anonymousRouter, tenant);
|
||||||
authnRoutes(anonymousRouter, tenant);
|
authnRoutes(anonymousRouter, tenant);
|
||||||
|
|
||||||
if (EnvSet.values.isDevFeaturesEnabled) {
|
wellKnownOpenApiRoutes(anonymousRouter, {
|
||||||
const profileRouter: ProfileRouter = new Router();
|
experienceRouters: [experienceRouter, interactionRouter],
|
||||||
profileRoutes(profileRouter, tenant);
|
managementRouters: [managementRouter, anonymousRouter],
|
||||||
}
|
userRouters: [userRouter],
|
||||||
|
});
|
||||||
|
|
||||||
// The swagger.json should contain all API routers.
|
// The swagger.json should contain all API routers.
|
||||||
swaggerRoutes(anonymousRouter, [
|
swaggerRoutes(anonymousRouter, [
|
||||||
managementRouter,
|
managementRouter,
|
||||||
anonymousRouter,
|
anonymousRouter,
|
||||||
experienceRouter,
|
experienceRouter,
|
||||||
|
userRouter,
|
||||||
// TODO: interactionRouter should be removed from swagger.json
|
// TODO: interactionRouter should be removed from swagger.json
|
||||||
interactionRouter,
|
interactionRouter,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter];
|
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter, userRouter];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function initApis(tenant: TenantContext): Koa {
|
export default function initApis(tenant: TenantContext): Koa {
|
||||||
|
|
44
packages/core/src/routes/profile/index.openapi.json
Normal file
44
packages/core/src/routes/profile/index.openapi.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,32 +2,51 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
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 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';
|
||||||
|
|
||||||
/**
|
export default function profileRoutes<T extends UserRouter>(
|
||||||
* Authn stands for authentication.
|
...[router, { provider, queries, libraries }]: RouterInitArgs<T>
|
||||||
* 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>
|
|
||||||
) {
|
) {
|
||||||
|
const {
|
||||||
|
users: { updateUserById },
|
||||||
|
} = queries;
|
||||||
|
|
||||||
router.use(koaOidcAuth(provider));
|
router.use(koaOidcAuth(provider));
|
||||||
|
|
||||||
// TODO: test route only, will implement a better one later
|
if (!EnvSet.values.isDevFeaturesEnabled) {
|
||||||
router.get(
|
return;
|
||||||
'/profile',
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/profile/password',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
response: z.object({
|
body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }),
|
||||||
sub: z.string(),
|
status: [204, 400],
|
||||||
}),
|
|
||||||
status: [200],
|
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
ctx.body = {
|
const { id: userId } = ctx.auth;
|
||||||
sub: ctx.auth.id,
|
const { password, verificationRecordId } = ctx.guard.body;
|
||||||
};
|
|
||||||
ctx.status = 200;
|
// 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();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,3 +57,8 @@ curl --location \\
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Replace \`[tenant-id]\` with your Logto tenant ID and \`eyJhbG...2g\` with the access token you fetched earlier.`;
|
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.
|
||||||
|
`;
|
||||||
|
|
|
@ -13,7 +13,7 @@ import assertThat from '#src/utils/assert-that.js';
|
||||||
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
import { translationSchemas } from '#src/utils/zod.js';
|
import { translationSchemas } from '#src/utils/zod.js';
|
||||||
|
|
||||||
import { managementApiAuthDescription } from '../consts.js';
|
import { managementApiAuthDescription, userApiAuthDescription } from '../consts.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type FindSupplementFilesOptions,
|
type FindSupplementFilesOptions,
|
||||||
|
@ -152,6 +152,60 @@ export const buildExperienceApiBaseDocument = (
|
||||||
tags: [...tags].map((tag) => ({ name: tag })),
|
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 (
|
export const getSupplementDocuments = async (
|
||||||
directory = 'routes',
|
directory = 'routes',
|
||||||
option?: FindSupplementFilesOptions
|
option?: FindSupplementFilesOptions
|
||||||
|
|
|
@ -24,10 +24,7 @@ const methodToVerb = Object.freeze({
|
||||||
|
|
||||||
type RouteDictionary = Record<`${OpenAPIV3.HttpMethods} ${string}`, string>;
|
type RouteDictionary = Record<`${OpenAPIV3.HttpMethods} ${string}`, string>;
|
||||||
|
|
||||||
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({
|
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({});
|
||||||
// Subject tokens
|
|
||||||
'post /subject-tokens': 'CreateSubjectToken',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const customRoutes: Readonly<RouteDictionary> = Object.freeze({
|
export const customRoutes: Readonly<RouteDictionary> = Object.freeze({
|
||||||
// Authn
|
// Authn
|
||||||
|
@ -127,6 +124,8 @@ const exceptionPrefixes = Object.freeze([
|
||||||
'/interaction',
|
'/interaction',
|
||||||
'/experience',
|
'/experience',
|
||||||
'/sign-in-exp/default/check-password',
|
'/sign-in-exp/default/check-password',
|
||||||
|
'/profile',
|
||||||
|
'/verifications',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isPathParameter = (segment?: string) =>
|
const isPathParameter = (segment?: string) =>
|
||||||
|
|
|
@ -17,7 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext &
|
||||||
|
|
||||||
export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;
|
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;
|
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
|
||||||
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;
|
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;
|
||||||
|
|
41
packages/core/src/routes/verification/index.openapi.json
Normal file
41
packages/core/src/routes/verification/index.openapi.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
60
packages/core/src/routes/verification/index.ts
Normal file
60
packages/core/src/routes/verification/index.ts
Normal 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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
buildManagementApiBaseDocument,
|
buildManagementApiBaseDocument,
|
||||||
getSupplementDocuments,
|
getSupplementDocuments,
|
||||||
assembleSwaggerDocument,
|
assembleSwaggerDocument,
|
||||||
|
buildUserApiBaseDocument,
|
||||||
} from '#src/routes/swagger/utils/documents.js';
|
} from '#src/routes/swagger/utils/documents.js';
|
||||||
import {
|
import {
|
||||||
buildRouterObjects,
|
buildRouterObjects,
|
||||||
|
@ -14,11 +15,12 @@ import { type AnonymousRouter } from '#src/routes/types.js';
|
||||||
type OpenApiRouters<R> = {
|
type OpenApiRouters<R> = {
|
||||||
managementRouters: R[];
|
managementRouters: R[];
|
||||||
experienceRouters: R[];
|
experienceRouters: R[];
|
||||||
|
userRouters: R[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function openapiRoutes<T extends AnonymousRouter, R extends UnknownRouter>(
|
export default function openapiRoutes<T extends AnonymousRouter, R extends UnknownRouter>(
|
||||||
router: T,
|
router: T,
|
||||||
{ managementRouters, experienceRouters }: OpenApiRouters<R>
|
{ managementRouters, experienceRouters, userRouters }: OpenApiRouters<R>
|
||||||
) {
|
) {
|
||||||
router.get('/.well-known/management.openapi.json', async (ctx, next) => {
|
router.get('/.well-known/management.openapi.json', async (ctx, next) => {
|
||||||
const managementApiRoutes = buildRouterObjects(managementRouters, {
|
const managementApiRoutes = buildRouterObjects(managementRouters, {
|
||||||
|
@ -29,7 +31,7 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
|
||||||
|
|
||||||
// Find supplemental documents
|
// Find supplemental documents
|
||||||
const supplementDocuments = await getSupplementDocuments('routes', {
|
const supplementDocuments = await getSupplementDocuments('routes', {
|
||||||
excludeDirectories: ['experience', 'interaction'],
|
excludeDirectories: ['experience', 'interaction', 'profile', 'verification'],
|
||||||
});
|
});
|
||||||
const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin);
|
const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin);
|
||||||
|
|
||||||
|
@ -62,4 +64,24 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
|
||||||
|
|
||||||
return next();
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
||||||
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
||||||
|
|
||||||
import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js';
|
import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js';
|
||||||
|
import { VerificationRecordQueries } from '../queries/verification-records.js';
|
||||||
|
|
||||||
export default class Queries {
|
export default class Queries {
|
||||||
applications = createApplicationQueries(this.pool);
|
applications = createApplicationQueries(this.pool);
|
||||||
|
@ -59,6 +60,7 @@ export default class Queries {
|
||||||
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
|
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
|
||||||
subjectTokens = createSubjectTokenQueries(this.pool);
|
subjectTokens = createSubjectTokenQueries(this.pool);
|
||||||
personalAccessTokens = new PersonalAccessTokensQueries(this.pool);
|
personalAccessTokens = new PersonalAccessTokensQueries(this.pool);
|
||||||
|
verificationRecords = new VerificationRecordQueries(this.pool);
|
||||||
tenants = createTenantQueries(this.pool);
|
tenants = createTenantQueries(this.pool);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
7
packages/integration-tests/src/api/profile.ts
Normal file
7
packages/integration-tests/src/api/profile.ts
Normal 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 } });
|
13
packages/integration-tests/src/api/verification-record.ts
Normal file
13
packages/integration-tests/src/api/verification-record.ts
Normal 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;
|
||||||
|
};
|
|
@ -29,7 +29,7 @@ import {
|
||||||
export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId);
|
export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId);
|
||||||
export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me');
|
export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me');
|
||||||
|
|
||||||
const createUserWithRoles = async (roleNames: string[]) => {
|
export const createUserWithPassword = async () => {
|
||||||
const username = generateUsername();
|
const username = generateUsername();
|
||||||
const password = generatePassword();
|
const password = generatePassword();
|
||||||
const user = await api
|
const user = await api
|
||||||
|
@ -38,6 +38,12 @@ const createUserWithRoles = async (roleNames: string[]) => {
|
||||||
})
|
})
|
||||||
.json<User>();
|
.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
|
// Should have roles for default tenant Management API and admin tenant Me API
|
||||||
const roles = await api.get('roles').json<Role[]>();
|
const roles = await api.get('roles').json<Role[]>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
@ -108,6 +114,16 @@ export const initClientAndSignIn = async (
|
||||||
return client;
|
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 () => {
|
export const createUserWithAllRolesAndSignInToClient = async () => {
|
||||||
const [{ id }, { username, password }] = await createUserWithAllRoles();
|
const [{ id }, { username, password }] = await createUserWithAllRoles();
|
||||||
const client = await initClientAndSignIn(username, password, {
|
const client = await initClientAndSignIn(username, password, {
|
||||||
|
|
|
@ -23,6 +23,7 @@ const identifiersTypeToUserProfile = Object.freeze({
|
||||||
username: 'username',
|
username: 'username',
|
||||||
email: 'primaryEmail',
|
email: 'primaryEmail',
|
||||||
phone: 'primaryPhone',
|
phone: 'primaryPhone',
|
||||||
|
userId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sign-in with password verification happy path', () => {
|
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()]);
|
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',
|
'should sign-in with password using %p',
|
||||||
async (identifier) => {
|
async (identifier) => {
|
||||||
const { userProfile, user } = await generateNewUser({
|
const { userProfile, user } = await generateNewUser({
|
||||||
|
|
|
@ -8,10 +8,11 @@ const identifiersTypeToUserProfile = Object.freeze({
|
||||||
username: 'username',
|
username: 'username',
|
||||||
email: 'primaryEmail',
|
email: 'primaryEmail',
|
||||||
phone: 'primaryPhone',
|
phone: 'primaryPhone',
|
||||||
|
userId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('password verifications', () => {
|
describe('password verifications', () => {
|
||||||
it.each(Object.values(SignInIdentifier))(
|
it.each([SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone])(
|
||||||
'should verify with password successfully using %p',
|
'should verify with password successfully using %p',
|
||||||
async (identifier) => {
|
async (identifier) => {
|
||||||
const { userProfile, user } = await generateNewUser({
|
const { userProfile, user } = await generateNewUser({
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -7,7 +7,7 @@ import { adminTenantApi } from '#src/api/api.js';
|
||||||
const { default: OpenApiSchemaValidator } = Validator;
|
const { default: OpenApiSchemaValidator } = Validator;
|
||||||
|
|
||||||
describe('.well-known openapi.json endpoints', () => {
|
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`);
|
const response = await adminTenantApi.get(`.well-known/${type}.openapi.json`);
|
||||||
|
|
||||||
expect(response).toHaveProperty('status', 200);
|
expect(response).toHaveProperty('status', 200);
|
||||||
|
|
|
@ -23,6 +23,7 @@ import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
import user from './user.js';
|
import user from './user.js';
|
||||||
import verification_code from './verification-code.js';
|
import verification_code from './verification-code.js';
|
||||||
|
import verification_record from './verification-record.js';
|
||||||
|
|
||||||
const errors = {
|
const errors = {
|
||||||
request,
|
request,
|
||||||
|
@ -50,6 +51,7 @@ const errors = {
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
single_sign_on,
|
single_sign_on,
|
||||||
|
verification_record,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
const verification_record = {
|
||||||
|
not_found: 'Verification record not found.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(verification_record);
|
|
@ -49,6 +49,10 @@ export enum SignInIdentifier {
|
||||||
|
|
||||||
export const signInIdentifierGuard = z.nativeEnum(SignInIdentifier);
|
export const signInIdentifierGuard = z.nativeEnum(SignInIdentifier);
|
||||||
|
|
||||||
|
export enum AdditionalIdentifier {
|
||||||
|
UserId = 'userId',
|
||||||
|
}
|
||||||
|
|
||||||
export const signUpGuard = z.object({
|
export const signUpGuard = z.object({
|
||||||
identifiers: z.nativeEnum(SignInIdentifier).array(),
|
identifiers: z.nativeEnum(SignInIdentifier).array(),
|
||||||
password: z.boolean(),
|
password: z.boolean(),
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AdditionalIdentifier,
|
||||||
MfaFactor,
|
MfaFactor,
|
||||||
SignInIdentifier,
|
SignInIdentifier,
|
||||||
jsonObjectGuard,
|
jsonObjectGuard,
|
||||||
|
@ -28,6 +29,16 @@ export enum InteractionEvent {
|
||||||
ForgotPassword = 'ForgotPassword',
|
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 ======
|
// ====== Experience API payload guards and type definitions start ======
|
||||||
|
|
||||||
/** Identifiers that can be used to uniquely identify a user. */
|
/** Identifiers that can be used to uniquely identify a user. */
|
||||||
|
|
Loading…
Reference in a new issue