diff --git a/packages/core/src/libraries/verification.ts b/packages/core/src/libraries/verification.ts new file mode 100644 index 000000000..b8057efd9 --- /dev/null +++ b/packages/core/src/libraries/verification.ts @@ -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(), + }); +}; diff --git a/packages/core/src/queries/verification-records.ts b/packages/core/src/queries/verification-records.ts new file mode 100644 index 000000000..0ad7cb729 --- /dev/null +++ b/packages/core/src/queries/verification-records.ts @@ -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(sql` + select * from ${table} + where ${fields.userId} = ${userId} + and ${fields.id} = ${id} + and ${fields.expiresAt} > now() + `); + }; +} diff --git a/packages/core/src/routes/experience/classes/libraries/sentinel-guard.ts b/packages/core/src/routes/experience/classes/libraries/sentinel-guard.ts index 127df9137..dea1ef58f 100644 --- a/packages/core/src/routes/experience/classes/libraries/sentinel-guard.ts +++ b/packages/core/src/routes/experience/classes/libraries/sentinel-guard.ts @@ -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( }: { sentinel: Sentinel; action: SentinelActivityAction; - identifier: InteractionIdentifier; + identifier: VerificationIdentifier; payload: Record; }, verificationPromise: Promise diff --git a/packages/core/src/routes/experience/classes/utils.ts b/packages/core/src/routes/experience/classes/utils.ts index 25f98bb2b..e95674d04 100644 --- a/packages/core/src/routes/experience/classes/utils.ts +++ b/packages/core/src/routes/experience/classes/utils.ts @@ -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); + } } }; diff --git a/packages/core/src/routes/experience/classes/verifications/password-verification.ts b/packages/core/src/routes/experience/classes/verifications/password-verification.ts index edfc5e953..7443cf205 100644 --- a/packages/core/src/routes/experience/classes/verifications/password-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/password-verification.ts @@ -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; @@ -35,7 +35,7 @@ export class PasswordVerification implements IdentifierVerificationRecord { /** 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; diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 64deb50f4..a697cfbeb 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -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 { diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json new file mode 100644 index 000000000..da25c8f93 --- /dev/null +++ b/packages/core/src/routes/profile/index.openapi.json @@ -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." + } + } + } + } + } +} diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts index b7e9cc6f3..39fca4134 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -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( - ...[router, { provider }]: RouterInitArgs +export default function profileRoutes( + ...[router, { provider, queries, libraries }]: RouterInitArgs ) { + 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(); } diff --git a/packages/core/src/routes/swagger/consts.ts b/packages/core/src/routes/swagger/consts.ts index 8b08f1307..6cbb4183d 100644 --- a/packages/core/src/routes/swagger/consts.ts +++ b/packages/core/src/routes/swagger/consts.ts @@ -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. +`; diff --git a/packages/core/src/routes/swagger/utils/documents.ts b/packages/core/src/routes/swagger/utils/documents.ts index f0ad24932..2fe2ba2a2 100644 --- a/packages/core/src/routes/swagger/utils/documents.ts +++ b/packages/core/src/routes/swagger/utils/documents.ts @@ -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, + tags: Set, + 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 diff --git a/packages/core/src/routes/swagger/utils/operation-id.ts b/packages/core/src/routes/swagger/utils/operation-id.ts index ca682ca2b..7f206a184 100644 --- a/packages/core/src/routes/swagger/utils/operation-id.ts +++ b/packages/core/src/routes/swagger/utils/operation-id.ts @@ -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 = 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) => diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index 06aa0c152..c6e09905c 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -17,7 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext & export type ManagementApiRouter = Router; -export type ProfileRouter = Router; +export type UserRouter = Router; type RouterInit = (router: T, tenant: TenantContext) => void; export type RouterInitArgs = Parameters>; diff --git a/packages/core/src/routes/verification/index.openapi.json b/packages/core/src/routes/verification/index.openapi.json new file mode 100644 index 000000000..f422da80a --- /dev/null +++ b/packages/core/src/routes/verification/index.openapi.json @@ -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." + } + } + } + } + } +} diff --git a/packages/core/src/routes/verification/index.ts b/packages/core/src/routes/verification/index.ts new file mode 100644 index 000000000..680cf788d --- /dev/null +++ b/packages/core/src/routes/verification/index.ts @@ -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( + ...[router, { provider, queries, libraries, sentinel }]: RouterInitArgs +) { + 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(); + } + ); +} diff --git a/packages/core/src/routes/well-known/well-known.openapi.json b/packages/core/src/routes/well-known/well-known.openapi.json index f475da57b..8d24482a1 100644 --- a/packages/core/src/routes/well-known/well-known.openapi.json +++ b/packages/core/src/routes/well-known/well-known.openapi.json @@ -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." + } + } + } } } } diff --git a/packages/core/src/routes/well-known/well-known.openapi.ts b/packages/core/src/routes/well-known/well-known.openapi.ts index c4cad16fc..605daea67 100644 --- a/packages/core/src/routes/well-known/well-known.openapi.ts +++ b/packages/core/src/routes/well-known/well-known.openapi.ts @@ -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 = { managementRouters: R[]; experienceRouters: R[]; + userRouters: R[]; }; export default function openapiRoutes( router: T, - { managementRouters, experienceRouters }: OpenApiRouters + { managementRouters, experienceRouters, userRouters }: OpenApiRouters ) { router.get('/.well-known/management.openapi.json', async (ctx, next) => { const managementApiRoutes = buildRouterObjects(managementRouters, { @@ -29,7 +31,7 @@ export default function openapiRoutes { + 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(); + }); } diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index e776f6ba3..93e02c473 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -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( diff --git a/packages/integration-tests/src/api/profile.ts b/packages/integration-tests/src/api/profile.ts new file mode 100644 index 000000000..8c9a5ac3b --- /dev/null +++ b/packages/integration-tests/src/api/profile.ts @@ -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 } }); diff --git a/packages/integration-tests/src/api/verification-record.ts b/packages/integration-tests/src/api/verification-record.ts new file mode 100644 index 000000000..5e74378d0 --- /dev/null +++ b/packages/integration-tests/src/api/verification-record.ts @@ -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; +}; diff --git a/packages/integration-tests/src/helpers/admin-tenant.ts b/packages/integration-tests/src/helpers/admin-tenant.ts index cbc2e8869..65fc35191 100644 --- a/packages/integration-tests/src/helpers/admin-tenant.ts +++ b/packages/integration-tests/src/helpers/admin-tenant.ts @@ -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(); + 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(); 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, { diff --git a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts index 1bae707d2..75a88a244 100644 --- a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts @@ -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({ diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts index 352d1222d..e2e5d0eff 100644 --- a/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts @@ -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({ diff --git a/packages/integration-tests/src/tests/api/profile/index.test.ts b/packages/integration-tests/src/tests/api/profile/index.test.ts new file mode 100644 index 000000000..776f2fc3c --- /dev/null +++ b/packages/integration-tests/src/tests/api/profile/index.test.ts @@ -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); + }); + }); +}); diff --git a/packages/integration-tests/src/tests/api/well-known.openapi.test.ts b/packages/integration-tests/src/tests/api/well-known.openapi.test.ts index 1dde36fc1..fa5900b80 100644 --- a/packages/integration-tests/src/tests/api/well-known.openapi.test.ts +++ b/packages/integration-tests/src/tests/api/well-known.openapi.test.ts @@ -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); diff --git a/packages/phrases/src/locales/en/errors/index.ts b/packages/phrases/src/locales/en/errors/index.ts index 40df6998e..58f416285 100644 --- a/packages/phrases/src/locales/en/errors/index.ts +++ b/packages/phrases/src/locales/en/errors/index.ts @@ -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); diff --git a/packages/phrases/src/locales/en/errors/verification-record.ts b/packages/phrases/src/locales/en/errors/verification-record.ts new file mode 100644 index 000000000..fca3dee41 --- /dev/null +++ b/packages/phrases/src/locales/en/errors/verification-record.ts @@ -0,0 +1,5 @@ +const verification_record = { + not_found: 'Verification record not found.', +}; + +export default Object.freeze(verification_record); diff --git a/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts b/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts index d5e237094..b96b04273 100644 --- a/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts +++ b/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts @@ -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(), diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 64b92ed27..1141cb84c 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -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; + // ====== Experience API payload guards and type definitions start ====== /** Identifiers that can be used to uniquely identify a user. */