diff --git a/packages/console/src/consts/logs.ts b/packages/console/src/consts/logs.ts index 4d94b848a2b1..2db1ac2f757b 100644 --- a/packages/console/src/consts/logs.ts +++ b/packages/console/src/consts/logs.ts @@ -17,6 +17,7 @@ export const auditLogEventTitle = Object.freeze({ 'Interaction.Register.Update': 'Update register interaction', 'Interaction.SignIn.Profile.Update': 'Patch update sign-in interaction profile', 'Interaction.SignIn.Submit': 'Submit sign-in interaction', + 'Interaction.SignIn.StepUp.Submit': 'Submit step-up authentication', 'Interaction.SignIn.Update': 'Update sign-in interaction', 'Interaction.Register.Create': 'Create new register interaction', 'Interaction.SignIn.Create': 'Create new sign-in interaction', diff --git a/packages/core/src/routes/experience/classes/experience-interaction.test.ts b/packages/core/src/routes/experience/classes/experience-interaction.test.ts index 883337045118..77f731fa4a17 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.test.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.test.ts @@ -33,6 +33,7 @@ import { mockUser, mockUserWithMfaVerifications } from '#src/__mocks__/user.js'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { type InsertUserResult } from '#src/libraries/user.js'; +import { LogEntry } from '#src/middleware/koa-audit-log.js'; import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { MockTenant } from '#src/test-utils/tenant.js'; @@ -895,67 +896,67 @@ describe('ExperienceInteraction class', () => { }); }); - describe('step-up creation', () => { - const stepUpContext = { - requestedAcrValues: [LogtoAcr.Mfa], - selectedAcr: LogtoAcr.Mfa, - mode: AuthenticationContextMode.StepUp, - }; - const requestedOnlyContext = { requestedAcrValues: [LogtoAcr.Mfa, LogtoAcr.FirstFactor] }; - - const createInteraction = ({ - interactionEvent = InteractionEvent.SignIn, - details, - withoutSession = false, - user = mockUserWithMfaVerifications, - connectors = [], - }: { - interactionEvent?: InteractionEvent; - details?: Record; - /** Create the interaction without an authenticated session. */ - withoutSession?: boolean; - user?: User; - connectors?: Array<{ type: ConnectorType }>; - } = {}) => { - const interactionDetails = { - jti: 'session-id', - params: { client_id: adminConsoleApplicationId }, - prompt: { name: 'login', reasons: ['acr_unmet'], details }, - ...conditional( - !withoutSession && { - session: { accountId: user.id, acr: LogtoAcr.FirstFactor, amr: ['pwd'] }, - } - ), - } as unknown as Interaction; - const provider = createMockProvider(jest.fn().mockResolvedValue(interactionDetails)); - const stepUpTenant = new MockTenant( - provider, - { - users: { - ...userQueries, - findUserById: jest.fn().mockResolvedValue(user), - findUserByUsername: jest.fn().mockResolvedValue(user), - }, - signInExperiences: { - findDefaultSignInExperience: jest.fn().mockResolvedValue({ - ...mockSignInExperience, - mfa: { policy: MfaPolicy.UserControlled, factors: [MfaFactor.TOTP] }, - }), - }, + const stepUpContext = { + requestedAcrValues: [LogtoAcr.Mfa], + selectedAcr: LogtoAcr.Mfa, + mode: AuthenticationContextMode.StepUp, + }; + const requestedOnlyContext = { requestedAcrValues: [LogtoAcr.Mfa, LogtoAcr.FirstFactor] }; + + const createInteraction = ({ + interactionEvent = InteractionEvent.SignIn, + details, + withoutSession = false, + user = mockUserWithMfaVerifications, + connectors = [], + }: { + interactionEvent?: InteractionEvent; + details?: Record; + /** Create the interaction without an authenticated session. */ + withoutSession?: boolean; + user?: User; + connectors?: Array<{ type: ConnectorType }>; + } = {}) => { + const interactionDetails = { + jti: 'session-id', + params: { client_id: adminConsoleApplicationId }, + prompt: { name: 'login', reasons: ['acr_unmet'], details }, + ...conditional( + !withoutSession && { + session: { accountId: user.id, acr: LogtoAcr.FirstFactor, amr: ['pwd'] }, + } + ), + } as unknown as Interaction; + const provider = createMockProvider(jest.fn().mockResolvedValue(interactionDetails)); + const stepUpTenant = new MockTenant( + provider, + { + users: { + ...userQueries, + findUserById: jest.fn().mockResolvedValue(user), + findUserByUsername: jest.fn().mockResolvedValue(user), }, - { getLogtoConnectors: jest.fn().mockResolvedValue(connectors) }, - { users: userLibraries, ssoConnectors } - ); - const stepUpCtx: WithHooksAndLogsContext = { ...ctx, interactionDetails }; + signInExperiences: { + findDefaultSignInExperience: jest.fn().mockResolvedValue({ + ...mockSignInExperience, + mfa: { policy: MfaPolicy.UserControlled, factors: [MfaFactor.TOTP] }, + }), + }, + }, + { getLogtoConnectors: jest.fn().mockResolvedValue(connectors) }, + { users: userLibraries, ssoConnectors } + ); + const stepUpCtx: WithHooksAndLogsContext = { ...ctx, interactionDetails }; - return { - provider, - stepUpTenant, - stepUpCtx, - experienceInteraction: new ExperienceInteraction(stepUpCtx, stepUpTenant, interactionEvent), - }; + return { + provider, + stepUpTenant, + stepUpCtx, + experienceInteraction: new ExperienceInteraction(stepUpCtx, stepUpTenant, interactionEvent), }; + }; + describe('step-up creation', () => { it('pins the subject and sets the mode from a step-up prompt', async () => { const { experienceInteraction } = createInteraction({ details: { authenticationContext: stepUpContext }, @@ -1241,6 +1242,227 @@ describe('ExperienceInteraction class', () => { }); }); + describe('step-up submission', () => { + const firstFactorContext = { + requestedAcrValues: [LogtoAcr.FirstFactor], + selectedAcr: LogtoAcr.FirstFactor, + mode: AuthenticationContextMode.StepUp, + }; + + /** The account shape a step-up establishes methods for: no password, no primary identifier. */ + const userWithoutMethods: User = { + ...mockUserWithMfaVerifications, + passwordEncrypted: null, + primaryEmail: null, + primaryPhone: null, + }; + + /** A pure step-up whose pinned subject already answered a subject-bound password challenge. */ + const createIdentifiedStepUp = async ({ + details = { authenticationContext: firstFactorContext }, + user = mockUserWithMfaVerifications, + }: { + details?: Record; + user?: User; + } = {}) => { + const result = createInteraction({ details, user }); + const { libraries, queries } = result.stepUpTenant; + + result.experienceInteraction.setVerificationRecord( + new PasswordVerification(libraries, queries, { + id: 'password-verification-id', + type: VerificationType.Password, + identifier: { type: AdditionalIdentifier.UserId, value: user.id }, + verified: true, + }) + ); + await result.experienceInteraction.identifyUser('password-verification-id'); + + return result; + }; + + it('finishes the interaction with the context it achieved', async () => { + const { experienceInteraction, provider } = await createIdentifiedStepUp(); + + await expect(experienceInteraction.submitStepUp()).resolves.toBeUndefined(); + + expect(provider.interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + login: { + accountId: mockUserWithMfaVerifications.id, + acr: LogtoAcr.FirstFactor, + // `amr` describes only this interaction, never the session that carried the context in. + amr: [AuthenticationMethodReference.Password], + }, + }) + ); + }); + + it('combines the carried context with an MFA challenge and applies no tenant MFA policy', async () => { + const { experienceInteraction, provider, stepUpTenant } = createInteraction({ + details: { authenticationContext: stepUpContext }, + }); + const { libraries, queries } = stepUpTenant; + + experienceInteraction.setVerificationRecord( + new TotpVerification(libraries, queries, { + id: 'totp-verification-id', + type: VerificationType.TOTP, + userId: mockUserWithMfaVerifications.id, + verified: true, + }) + ); + experienceInteraction.consumeForMfa(VerificationType.TOTP, 'totp-verification-id'); + + await experienceInteraction.submitStepUp(); + + expect(provider.interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + login: { + accountId: mockUserWithMfaVerifications.id, + acr: LogtoAcr.Mfa, + amr: [AuthenticationMethodReference.Otp, AuthenticationMethodReference.Mfa], + }, + }) + ); + // A pure step-up never reads the tenant's MFA policy or any other sign-in experience setting. + expect( + stepUpTenant.queries.signInExperiences.findDefaultSignInExperience + ).not.toHaveBeenCalled(); + }); + + it('rejects a submission that did not reach the selected class without writing a result', async () => { + const { experienceInteraction, provider } = await createIdentifiedStepUp({ + details: { authenticationContext: stepUpContext }, + }); + + await expect(experienceInteraction.submitStepUp()).rejects.toMatchError( + new RequestError({ code: 'session.step_up.acr_not_satisfied', status: 403 }) + ); + expect(provider.interactionResult).not.toHaveBeenCalled(); + }); + + it('rejects a submission that verified nothing at all', async () => { + const { experienceInteraction, provider } = createInteraction({ + details: { authenticationContext: firstFactorContext }, + }); + + await expect(experienceInteraction.submitStepUp()).rejects.toMatchError( + new RequestError({ code: 'session.step_up.acr_not_satisfied', status: 403 }) + ); + expect(provider.interactionResult).not.toHaveBeenCalled(); + }); + + it('rejects an interaction that carries only a requested context', async () => { + const { experienceInteraction, provider } = createInteraction({ + details: { authenticationContext: requestedOnlyContext }, + withoutSession: true, + }); + + await expect(experienceInteraction.submitStepUp()).rejects.toMatchError( + new RequestError({ code: 'session.step_up.invalid_interaction_event', status: 400 }) + ); + expect(provider.interactionResult).not.toHaveBeenCalled(); + }); + + it.each([ + { name: 'a successful', rejected: false }, + { name: 'a rejected', rejected: true }, + ])('records the step-up payload of $name submission in the audit log', async ({ rejected }) => { + const { experienceInteraction } = rejected + ? await createIdentifiedStepUp({ details: { authenticationContext: stepUpContext } }) + : await createIdentifiedStepUp(); + const log = new LogEntry('Interaction.SignIn.StepUp.Submit'); + const submission = experienceInteraction.submitStepUp(log); + + await (rejected + ? expect(submission).rejects.toMatchError( + new RequestError({ code: 'session.step_up.acr_not_satisfied', status: 403 }) + ) + : expect(submission).resolves.toBeUndefined()); + + // The requested and achieved classes and the proved factor families are recorded; the + // outcome itself is stamped on the entry by the audit-log middleware. + expect(log.payload).toMatchObject({ + requestedAcrValues: rejected ? [LogtoAcr.Mfa] : [LogtoAcr.FirstFactor], + selectedAcr: rejected ? LogtoAcr.Mfa : LogtoAcr.FirstFactor, + achievedAcr: LogtoAcr.FirstFactor, + factors: [AuthenticationFactor.Password], + }); + expect(JSON.stringify(log.payload)).not.toContain('password-verification-id'); + }); + + it('rejects a submission whose counted proof never identified the subject', async () => { + const { experienceInteraction, provider, stepUpTenant } = createInteraction({ + details: { authenticationContext: firstFactorContext }, + }); + + // Establishing a method records its proof without identifying anyone. The allow-list keeps + // the establishment route closed, so this is the shape the 404 guard is here for. + experienceInteraction.profile.unsafeSet({ + passwordEncrypted: 'new-encrypted-password', + passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i, + }); + + await expect(experienceInteraction.submitStepUp()).rejects.toMatchError( + new RequestError({ code: 'session.identifier_not_found', status: 404 }) + ); + expect(provider.interactionResult).not.toHaveBeenCalled(); + expect(stepUpTenant.queries.users.updateUserById).not.toHaveBeenCalled(); + }); + + it('revalidates the identifier it is about to write', async () => { + const { experienceInteraction, stepUpTenant } = await createIdentifiedStepUp({ + user: userWithoutMethods, + }); + + jest.mocked(stepUpTenant.queries.users.hasUserWithEmail).mockResolvedValueOnce(true); + experienceInteraction.profile.unsafeSet({ primaryEmail: 'taken@example.com' }); + + await expect(experienceInteraction.submitStepUp()).rejects.toMatchError( + new RequestError({ code: 'user.email_already_in_use', status: 422 }) + ); + expect(stepUpTenant.queries.users.updateUserById).not.toHaveBeenCalled(); + }); + + it('writes only what the interaction established and never `lastSignInAt`', async () => { + jest.clearAllMocks(); + const { experienceInteraction, stepUpTenant, stepUpCtx } = await createIdentifiedStepUp({ + user: userWithoutMethods, + }); + const updateUserById = jest.mocked(stepUpTenant.queries.users.updateUserById); + + await experienceInteraction.submitStepUp(); + + // Verifying an existing method writes nothing: no user row, no hook context. + expect(updateUserById).not.toHaveBeenCalled(); + expect(stepUpCtx.assignReleaseOnSuccessInteractionHookResult).not.toHaveBeenCalled(); + expect(stepUpCtx.appendDataHookContext).not.toHaveBeenCalled(); + + experienceInteraction.profile.unsafeSet({ + passwordEncrypted: 'new-encrypted-password', + passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i, + }); + await experienceInteraction.submitStepUp(); + + expect(updateUserById).toHaveBeenCalledWith( + mockUserWithMfaVerifications.id, + expect.objectContaining({ + passwordEncrypted: 'new-encrypted-password', + passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i, + isPasswordExpired: false, + }) + ); + expect(updateUserById).toHaveBeenCalledTimes(1); + // `lastSignInAt` is the sign-in's to write; a step-up leaves it where the sign-in left it. + expect(updateUserById.mock.calls[0]?.[1]).not.toHaveProperty('lastSignInAt'); + }); + }); + describe('guardMfaVerificationStatus', () => { it('skips MFA verification check when sign-in passkey is already verified', async () => { const { libraries, queries } = tenant; diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index fd45d89bb022..c4ba58dc5071 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -8,6 +8,7 @@ import { InteractionEvent, InteractionHookEvent, LogtoActionKey, + acrSatisfies, loginPromptAuthenticationContextDetailsGuard, MfaFactor, type InteractionAuthenticationContext, @@ -1007,6 +1008,81 @@ export default class ExperienceInteraction { } } + /** + * Complete a pure step-up interaction. + * + * This is an allow-list, never a `submit()` with exemptions: a sign-in guard or side effect added + * later cannot reach a step-up by default. It asserts the interaction reached the class the + * authorization request selected, writes only what the interaction established to the account, + * records the payload of the dedicated step-up audit key, and finishes the provider interaction. + * + * Deliberately not run, unlike {@link submit}: the captcha guard, the tenant MFA and profile + * policies, the passkey suggestion, every other `updateUserById` field including `lastSignInAt`, + * SSO identity synchronization, social / SSO token-set upserts, JIT organization provisioning, + * `triggerPostSignInAction`, and data-hook contexts. + * + * @throws {RequestError} with 400 if the interaction is not a pure step-up + * @throws {RequestError} with 403 if the achieved context does not satisfy `selectedAcr`; the + * assertion runs before the subject is read, so a submission that counted no verification ends + * here rather than as a missing subject + * @throws {RequestError} with 404 if a counted proof never identified the subject, which the + * allow-list blocks today: identification and an answered MFA challenge both set the user, and + * the only proof that does not is a `bind` + */ + public async submitStepUp(log?: LogEntry) { + const { authenticationContext, authenticationProofs } = this; + + // Only a pure step-up completes here: the OIDC policy writes the mode and `selectedAcr` + // together, and a `SignIn` with a requested ACR keeps neither. + assertThat( + this.isStepUp && authenticationContext?.selectedAcr, + new RequestError({ code: 'session.step_up.invalid_interaction_event', status: 400 }) + ); + + const { requestedAcrValues, selectedAcr } = authenticationContext; + + // The session's carried context only ever pairs with a proof of this interaction, so a + // submission that counted no verification derives nothing and fails the assertion below. + const achievedContext = aggregateAuthenticationContext( + authenticationProofs.proofs, + this.carriedContributions + ); + + log?.append({ + requestedAcrValues, + selectedAcr, + achievedAcr: achievedContext.acr, + // The factor families the interaction proved. Auditable and unambiguous, unlike `amr`, where + // `otp` alone cannot tell an email code from a TOTP or a backup code. Credentials never reach + // the log; the audit-log filters already cover passwords, codes, WebAuthn and backup codes. + factors: [...new Set(authenticationProofs.proofs.map(({ factor }) => factor))], + }); + + // The UI only offers sufficient methods; this is defense in depth. Thrown before anything is + // written, so a rejected submission leaves no interaction result behind. + assertThat( + acrSatisfies(achievedContext.acr, selectedAcr), + new RequestError({ code: 'session.step_up.acr_not_satisfied', status: 403 }) + ); + + const user = await this.getIdentifiedUser(); + + await this.persistEstablishedMethods(user); + + const { provider } = this.tenant; + + const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, { + login: { + accountId: user.id, + ...achievedContext, + }, + // Persist the interaction status to the OIDC session after interaction submission + ...this.toJson(), + }); + + this.ctx.body = { redirectTo }; + } + async guardCaptcha() { // Pure step-up already has an authenticated OIDC session. if (this.isStepUp || this.captcha.verified || this.captcha.skipped) { @@ -1063,6 +1139,65 @@ export default class ExperienceInteraction { }; } + /** + * Write only what the interaction established to the account: the MFA factors it bound, and the + * first factor (password or primary email / phone) it staged on the profile. Establishing is + * wired in a later milestone, so a step-up that only verified existing methods has nothing here + * and no query runs at all. Nothing else about the user is touched: in particular `lastSignInAt` + * keeps the value the sign-in wrote, because a step-up is not a sign-in. + */ + private async persistEstablishedMethods(user: User) { + const { passwordEncrypted, passwordEncryptionMethod, primaryEmail, primaryPhone } = + this.profile.data; + const userMfaVerifications = this.mfa.toUserMfaVerifications(); + const { mfaVerifications } = userMfaVerifications; + + const established = { + ...conditional( + passwordEncrypted && + passwordEncryptionMethod && + buildUserPasswordPayload({ + passwordEncrypted, + passwordEncryptionMethod, + }) + ), + ...conditional(primaryEmail && { primaryEmail }), + ...conditional(primaryPhone && { primaryPhone }), + ...conditional( + mfaVerifications.length > 0 && { + mfaVerifications: mergeUserMfaVerifications(user.mfaVerifications, mfaVerifications), + // Only written together with a bound factor: it is the persisted state of the MFA setup + // this interaction completed, not a sign-in side effect. + logtoConfig: { + ...parseMfaPropertiesToUserConfig( + user.logtoConfig, + userMfaVerifications, + InteractionEvent.SignIn + ), + }, + } + ), + }; + + if (Object.keys(established).length === 0) { + return; + } + + // Revalidate what is about to be written, exactly as `submit()` does: everything here was + // staged by an earlier request, so an identifier can have been taken by another account, or a + // factor disabled, in between. The step-up allow-list keeps the establishment and enrollment + // routes closed until M5, so nothing reaches this write yet; the guards are here so that the + // milestone which opens them cannot skip the uniqueness check and turn a duplicate identifier + // into a raw unique-constraint error instead of the 422 the API promises. + await this.profile.validateAvailability(); + + if (mfaVerifications.length > 0) { + await this.mfa.checkAvailability(); + } + + await this.tenant.queries.users.updateUserById(user.id, established); + } + private async hasEligibleTrustedDeviceProof(user: User) { const mfaSettings = await this.signInExperienceValidator.getMfaSettings(); const mfaValidator = new MfaValidator(mfaSettings, user); diff --git a/packages/core/src/routes/experience/experience.step-up.openapi.json b/packages/core/src/routes/experience/experience.step-up.openapi.json new file mode 100644 index 000000000000..5dbf4f1ffb41 --- /dev/null +++ b/packages/core/src/routes/experience/experience.step-up.openapi.json @@ -0,0 +1,19 @@ +{ + "tags": [ + { + "name": "Dev feature" + } + ], + "paths": { + "/api/experience/submit": { + "post": { + "description": "Submit the current interaction.
- Submit the verified user identity to the OIDC provider for further authentication (SignIn and Register).
- Update the user's profile data if any (SignIn and Register).
- Reset the password and clear all the interaction records (ForgotPassword).
- Complete a step-up interaction: the interaction must have reached the authentication context class the authorization request selected, and only the methods it established are written to the account, with no sign-in side effect.", + "responses": { + "403": { + "description": "Multi-Factor Authentication (MFA) is enabled for the user but has not been verified (SignIn and Register).
- A step-up interaction did not reach the requested authentication context class (`session.step_up.acr_not_satisfied`); no interaction result is written." + } + } + } + } + } +} diff --git a/packages/core/src/routes/experience/index.test.ts b/packages/core/src/routes/experience/index.test.ts index 892c0f96135d..122b675a0af6 100644 --- a/packages/core/src/routes/experience/index.test.ts +++ b/packages/core/src/routes/experience/index.test.ts @@ -99,19 +99,25 @@ const buildTrustedDevice = (userId: string): TrustedDevice => const createLogMiddleware = (): { middleware: Middleware; + createLog: jest.Mock; mockAppend: jest.Mock; } => { - const { createLog, prependAllLogEntries, mockAppend } = createMockLogContext(); + const logContext = createMockLogContext(); const middleware: Middleware = async (ctx, next) => { // @ts-expect-error -- mock log context - ctx.createLog = createLog; + ctx.createLog = logContext.createLog; // @ts-expect-error -- mock log context - ctx.prependAllLogEntries = prependAllLogEntries; + ctx.prependAllLogEntries = logContext.prependAllLogEntries; return next(); }; - return { middleware, mockAppend }; + // `createMockLogContext` types `createLog` as the context member, while it really is a jest mock. + return { + middleware, + createLog: jest.mocked(logContext.createLog), + mockAppend: logContext.mockAppend, + }; }; const createRequesterWithMocks = ({ @@ -232,7 +238,7 @@ const createRequesterWithMocks = ({ } ); - const { middleware: logMiddleware, mockAppend } = createLogMiddleware(); + const { middleware: logMiddleware, createLog, mockAppend } = createLogMiddleware(); const requester = createRequester({ anonymousRoutes: experienceRoutes, tenantContext: tenant, @@ -243,6 +249,7 @@ const createRequesterWithMocks = ({ requester, userGeoLocations, userSignInCountries, + createLog, mockAppend, users, provider, @@ -540,7 +547,8 @@ describe('step-up route allow-list', () => { const { requester } = createStoredContextRequester(stepUpContext); const response = await requester.post('/experience/submit'); - expect(response.status).not.toBe(403); + // The route stays reachable; the submission itself fails the ACR assertion, not the allow-list. + expect(response.body).not.toMatchObject({ code: 'session.step_up.forbidden_route' }); }); it('should not restrict a SignIn with a requested ACR', async () => { @@ -606,6 +614,33 @@ describe('POST /experience/submit', () => { jest.restoreAllMocks(); }); + it('should submit a pure step-up under the step-up audit key and reject an unmet context', async () => { + const { requester, createLog } = createRequesterWithMocks({ + interactionResult: { + authenticationContext: { + requestedAcrValues: [LogtoAcr.Mfa], + selectedAcr: LogtoAcr.Mfa, + mode: AuthenticationContextMode.StepUp, + }, + }, + }); + + const response = await requester.post('/experience/submit'); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ code: 'session.step_up.acr_not_satisfied' }); + expect(createLog).toHaveBeenCalledWith('Interaction.SignIn.StepUp.Submit'); + }); + + it('should keep the sign-in submit path and audit key for a non-step-up interaction', async () => { + const { requester, createLog } = createRequesterWithMocks(); + + const response = await requester.post('/experience/submit'); + + expect(response.status).toBe(200); + expect(createLog).toHaveBeenCalledWith('Interaction.SignIn.Submit'); + }); + it('should record geo context when dev features are disabled', async () => { setDevFeaturesEnabled(false); const { requester, userGeoLocations, userSignInCountries } = createRequesterWithMocks(); diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 54e83b9bf5f0..1523d042d593 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -208,10 +208,19 @@ export default function experienceApiRoutes( }), async (ctx, next) => { const { createLog, experienceInteraction } = ctx; + const { interactionEvent, isStepUp } = experienceInteraction; - const log = createLog(`Interaction.${experienceInteraction.interactionEvent}.Submit`); + // A pure step-up completes through its own allow-list path and has its own audit key, so a + // step-up submission is never mistaken for a sign-in in the audit log. + const log = createLog( + isStepUp + ? `Interaction.${interactionEvent}.StepUp.Submit` + : `Interaction.${interactionEvent}.Submit` + ); - await ctx.experienceInteraction.submit(log); + await (isStepUp + ? experienceInteraction.submitStepUp(log) + : experienceInteraction.submit(log)); log.append({ interaction: ctx.experienceInteraction.toJson(), diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index 789c3405636e..0bd2a0b1782e 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -11,6 +11,7 @@ import PageContextProvider from './Providers/PageContextProvider'; import SettingsProvider from './Providers/SettingsProvider'; import UserInteractionContextProvider from './Providers/UserInteractionContextProvider'; import DevelopmentTenantNotification from './containers/DevelopmentTenantNotification'; +import StepUpGuard from './containers/StepUpGuard'; import Callback from './pages/Callback'; import Consent from './pages/Consent'; import Continue from './pages/Continue'; @@ -51,6 +52,9 @@ import SingleSignOnLanding from './pages/SingleSignOnLanding'; import SocialLanding from './pages/SocialLanding'; import SocialLinkAccount from './pages/SocialLinkAccount'; import SocialSignInWebCallback from './pages/SocialSignInWebCallback'; +import StepUp from './pages/StepUp'; +import StepUpPassword from './pages/StepUp/Password'; +import StepUpVerificationCode from './pages/StepUp/VerificationCode'; import SwitchAccount from './pages/SwitchAccount'; import TrustedDevice from './pages/TrustedDevice'; import VerificationCode from './pages/VerificationCode'; @@ -177,6 +181,20 @@ const App = () => { } /> + {/* + * Step-up: an authenticated session proves the missing assurance. The guard + * loads the server-driven context, the landing dispatches on it, and the + * pinned-user first-factor pages verify the pinned subject. + */} + }> + } /> + } /> + } + /> + + {/* Social sign-in pages */} } /> diff --git a/packages/experience/src/Providers/StepUpContextProvider/StepUpContext.tsx b/packages/experience/src/Providers/StepUpContextProvider/StepUpContext.tsx new file mode 100644 index 000000000000..b0568a468212 --- /dev/null +++ b/packages/experience/src/Providers/StepUpContextProvider/StepUpContext.tsx @@ -0,0 +1,27 @@ +import { type InteractionAuthenticationContext } from '@logto/schemas'; +import { createContext } from 'react'; + +export type StepUpContextType = { + /** + * The authentication context the server computed for this interaction: the selected class, + * the methods that can still contribute to it, and the masked identifiers. `undefined` until + * the first load settles, and when the interaction is gone or carries no context. + */ + authenticationContext?: InteractionAuthenticationContext; + /** + * Whether a load is in flight. The landing page waits for it before dispatching, so a stale + * context never decides where the user goes. + */ + isLoading: boolean; + /** Re-read the authoritative context from interaction storage. */ + refetch: () => Promise; +}; + +export default createContext({ + authenticationContext: undefined, + isLoading: true, + refetch: async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + return undefined; + }, +}); diff --git a/packages/experience/src/Providers/StepUpContextProvider/index.tsx b/packages/experience/src/Providers/StepUpContextProvider/index.tsx new file mode 100644 index 000000000000..89d85adca95b --- /dev/null +++ b/packages/experience/src/Providers/StepUpContextProvider/index.tsx @@ -0,0 +1,192 @@ +import { type LogtoErrorCode } from '@logto/phrases'; +import { type InteractionAuthenticationContext, type RequestErrorBody } from '@logto/schemas'; +import { HTTPError } from 'ky'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useMatch } from 'react-router-dom'; + +import { getStepUpContext, initStepUp } from '@/apis/experience'; +import { stepUpRoutes, stepUpSessionGoneErrorCodes } from '@/constants/step-up'; +import useApi from '@/hooks/use-api'; +import useErrorHandler from '@/hooks/use-error-handler'; +import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; + +import StepUpContext, { type StepUpContextType } from './StepUpContext'; + +type Props = { + readonly children: ReactNode; +}; + +/** The interaction storage holds nothing for this interaction yet, so it has to be created. */ +const interactionNotFoundCode: LogtoErrorCode = 'session.interaction_not_found'; + +const sessionGoneErrorCodes: ReadonlySet = new Set(stepUpSessionGoneErrorCodes); + +/** Read the Logto error code from a failed request without consuming the response body. */ +const getErrorCode = async (error: unknown): Promise => { + if (!(error instanceof HTTPError)) { + return; + } + + try { + const { code } = await error.response.clone().json(); + + return code; + } catch { + // A response without a JSON body carries no Logto error code. + } +}; + +/** + * Loads the server-driven step-up state for the `/step-up` route tree and exposes it through + * {@link StepUpContext}. + * + * Interaction storage is the single source of state, so the provider never reads flow state from + * `location.state`: + * + * - Every arrival at the landing page re-reads the context, and creates the interaction with + * `PUT /experience { interactionEvent: SignIn }` only when storage holds nothing for it yet. A + * refresh, or a return to the method list after a rejected submission, therefore keeps the + * proofs the interaction already recorded instead of starting over. + * - A child route reads the context once when it mounts, so a refresh recovers the same state. + * - When the interaction cannot proceed, `PUT /experience` answers `200 { redirectTo }` and the + * client is sent back to the application with `window.location.replace()`. + */ +const StepUpContextProvider = ({ children }: Props) => { + const [authenticationContext, setAuthenticationContext] = + useState(); + const [isFetching, setIsFetching] = useState(true); + const [loadedLandingKey, setLoadedLandingKey] = useState(); + const handledLandingKeyRef = useRef(); + const hasLoadedRef = useRef(false); + /** The id of the latest load; an older load that settles later must not overwrite it. */ + const loadIdRef = useRef(0); + + const asyncGetStepUpContext = useApi(getStepUpContext); + const asyncInitStepUp = useApi(initStepUp); + const handleError = useErrorHandler(); + const redirectTo = useGlobalRedirectTo(); + const { key } = useLocation(); + const isLanding = Boolean(useMatch(stepUpRoutes.landing)); + // The location key identifies one arrival at the landing page, so each arrival loads once. + const landingKey = isLanding ? key : undefined; + + /** A session that is gone lands on the invalid-session page silently; anything else also toasts. */ + const reportError = useCallback( + async (error: unknown) => { + const code = await getErrorCode(error); + + if (!code || !sessionGoneErrorCodes.has(code)) { + await handleError(error); + } + }, + [handleError] + ); + + /** + * Load the context, creating the interaction first when asked and storage holds nothing for + * it. Resolves with whether this load was still the latest one when it settled: a load that a + * newer one superseded writes nothing, so the state always reflects the latest arrival. + */ + const load = useCallback( + async (shouldInitialize: boolean): Promise => { + // eslint-disable-next-line @silverhand/fp/no-mutation + hasLoadedRef.current = true; + // eslint-disable-next-line @silverhand/fp/no-mutation + loadIdRef.current += 1; + const loadId = loadIdRef.current; + const isCurrent = () => loadIdRef.current === loadId; + const settle = (context?: InteractionAuthenticationContext) => { + if (isCurrent()) { + setAuthenticationContext(context); + setIsFetching(false); + } + + return isCurrent(); + }; + const fail = async (error: unknown) => { + await reportError(error); + + return settle(undefined); + }; + + setIsFetching(true); + + const [error, context] = await asyncGetStepUpContext(); + + if (!error) { + return settle(context); + } + + if (!shouldInitialize || (await getErrorCode(error)) !== interactionNotFoundCode) { + return fail(error); + } + + const [initError, result] = await asyncInitStepUp(); + + if (initError) { + return fail(initError); + } + + if (result?.redirectTo) { + // The interaction was finished with `unmet_authentication_requirements`; the application + // explains it. The redirect unloads the page, so this never resolves. + await redirectTo(result.redirectTo); + + return false; + } + + const [contextError, initializedContext] = await asyncGetStepUpContext(); + + if (contextError) { + return fail(contextError); + } + + return settle(initializedContext); + }, + [asyncGetStepUpContext, asyncInitStepUp, redirectTo, reportError] + ); + + const refetch = useCallback(async () => { + await load(false); + }, [load]); + + useEffect(() => { + if (landingKey === undefined) { + if (!hasLoadedRef.current) { + void load(false); + } + + return; + } + + if (handledLandingKeyRef.current === landingKey) { + return; + } + + // eslint-disable-next-line @silverhand/fp/no-mutation + handledLandingKeyRef.current = landingKey; + + const loadLanding = async () => { + if (await load(true)) { + setLoadedLandingKey(landingKey); + } + }; + + void loadLanding(); + }, [landingKey, load]); + + const stepUpContext = useMemo( + () => ({ + authenticationContext, + // A new arrival at the landing page is loading from its first render, before the effect + // runs, so the landing page never dispatches on the context of a previous visit. + isLoading: isFetching || (landingKey !== undefined && loadedLandingKey !== landingKey), + refetch, + }), + [authenticationContext, isFetching, landingKey, loadedLandingKey, refetch] + ); + + return {children}; +}; + +export default StepUpContextProvider; diff --git a/packages/experience/src/apis/experience/index.ts b/packages/experience/src/apis/experience/index.ts index d045c5b04d0c..c9f7ff37515a 100644 --- a/packages/experience/src/apis/experience/index.ts +++ b/packages/experience/src/apis/experience/index.ts @@ -32,6 +32,7 @@ export * from './mfa'; export * from './social'; export * from './one-time-token'; export * from './passkey-sign-in'; +export * from './step-up'; /** * For sign-in flow user identity not found error handling use. diff --git a/packages/experience/src/apis/experience/step-up.test.ts b/packages/experience/src/apis/experience/step-up.test.ts new file mode 100644 index 000000000000..85e164d859b0 --- /dev/null +++ b/packages/experience/src/apis/experience/step-up.test.ts @@ -0,0 +1,282 @@ +import { + AuthenticationContextMode, + InteractionEvent, + LogtoAcr, + MfaFactor, + MissingProfile, + SignInIdentifier, + VerificationType, + type InteractionAuthenticationContext, +} from '@logto/schemas'; + +import api from '../api'; + +import { experienceApiRoutes } from './const'; +import { identifyAndSubmitInteraction } from './interaction'; +import { + getStepUpContext, + initStepUp, + sendStepUpVerificationCode, + verifyStepUpPassword, + verifyStepUpVerificationCode, +} from './step-up'; + +jest.mock('./interaction', () => ({ + __esModule: true, + identifyAndSubmitInteraction: jest.fn(), +})); + +jest.mock('../api', () => ({ + __esModule: true, + default: { + put: jest.fn(), + get: jest.fn(), + post: jest.fn(), + }, +})); + +const mockedApiPut = api.put as jest.MockedFunction; +const mockedApiGet = api.get as jest.MockedFunction; +const mockedApiPost = api.post as jest.MockedFunction; +const mockedIdentifyAndSubmitInteraction = identifyAndSubmitInteraction as jest.MockedFunction< + typeof identifyAndSubmitInteraction +>; + +/** The fields the guard requires; `selectedAcr` and `mode` are optional on the wire. */ +const requiredContext = { + requestedAcrValues: [LogtoAcr.Mfa, LogtoAcr.FirstFactor], + availableMethods: [VerificationType.Password, VerificationType.EmailVerificationCode], + establishableMethods: [MissingProfile.password], + enrollableFactors: [MfaFactor.TOTP], + subjectProofConnectors: [{ type: 'social', connectorId: 'github' }], + maskedIdentifiers: { email: 'f***@logto.io', phone: '+1 ***5678' }, +} satisfies InteractionAuthenticationContext; + +const authenticationContext = { + ...requiredContext, + selectedAcr: LogtoAcr.Mfa, + mode: AuthenticationContextMode.StepUp, +} satisfies InteractionAuthenticationContext; + +const mockPutResponse = (status: number, body?: unknown) => { + const json = jest.fn().mockResolvedValue(body); + mockedApiPut.mockResolvedValueOnce({ status, json } as unknown as Awaited< + ReturnType + >); + + return json; +}; + +const mockPostResponse = (body: unknown) => { + const json = jest.fn().mockResolvedValue(body); + mockedApiPost.mockReturnValueOnce({ json } as unknown as ReturnType); + + return json; +}; + +const mockGetInteraction = (body: unknown) => { + const json = jest.fn().mockResolvedValue(body); + mockedApiGet.mockReturnValueOnce({ json } as unknown as ReturnType); + + return json; +}; + +describe('step-up experience APIs', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initStepUp', () => { + it('creates the sign-in interaction with only the interaction event', async () => { + mockPutResponse(204); + + await initStepUp(); + + expect(mockedApiPut).toBeCalledTimes(1); + const [url, options] = mockedApiPut.mock.calls[0] ?? []; + expect(url).toBe(experienceApiRoutes.prefix); + // Pinned strictly: nothing that identifies the user may ride along. + expect(options).toStrictEqual({ json: { interactionEvent: InteractionEvent.SignIn } }); + }); + + it('resolves with an empty result on 204 without reading a body', async () => { + const json = mockPutResponse(204); + + await expect(initStepUp()).resolves.toStrictEqual({}); + + expect(json).not.toBeCalled(); + }); + + it('resolves with the redirect target on 200', async () => { + const json = mockPutResponse(200, { redirectTo: 'https://app.example/callback' }); + + await expect(initStepUp()).resolves.toStrictEqual({ + redirectTo: 'https://app.example/callback', + }); + + expect(json).toBeCalledTimes(1); + }); + }); + + describe('getStepUpContext', () => { + it('reads the interaction and returns the parsed authentication context', async () => { + const json = mockGetInteraction({ authenticationContext }); + + await expect(getStepUpContext()).resolves.toStrictEqual(authenticationContext); + + expect(mockedApiGet).toBeCalledTimes(1); + expect(mockedApiGet).toBeCalledWith(experienceApiRoutes.interaction); + expect(json).toBeCalledTimes(1); + }); + + it('strips unknown fields from the authentication context', async () => { + mockGetInteraction({ + authenticationContext: { + ...authenticationContext, + unexpected: 'field', + maskedIdentifiers: { ...authenticationContext.maskedIdentifiers, username: 'foo' }, + }, + }); + + const result = await getStepUpContext(); + + expect(result).toStrictEqual(authenticationContext); + expect(result).not.toHaveProperty('unexpected'); + expect(result?.maskedIdentifiers).not.toHaveProperty('username'); + }); + + it('keeps optional fields absent when the server omits them', async () => { + mockGetInteraction({ authenticationContext: requiredContext }); + + const result = await getStepUpContext(); + + expect(result).toStrictEqual(requiredContext); + expect(result).not.toHaveProperty('selectedAcr'); + expect(result).not.toHaveProperty('mode'); + }); + + it('returns undefined when the interaction carries no authentication context', async () => { + mockGetInteraction({}); + + await expect(getStepUpContext()).resolves.toBeUndefined(); + }); + + it.each([ + ['availableMethods holds an unknown type', { availableMethods: ['Unknown'] }], + ['requestedAcrValues holds a non-Logto ACR', { requestedAcrValues: ['urn:custom:acr'] }], + ['selectedAcr is not a Logto ACR', { selectedAcr: 'urn:custom:acr' }], + ['mode is unknown', { mode: 'signIn' }], + ['enrollableFactors holds an unknown factor', { enrollableFactors: ['Unknown'] }], + ['establishableMethods holds an unknown profile', { establishableMethods: ['unknown'] }], + [ + 'subjectProofConnectors holds an unknown connector type', + { subjectProofConnectors: [{ type: 'oidc', connectorId: 'okta' }] }, + ], + ['maskedIdentifiers is missing', { maskedIdentifiers: undefined }], + ['availableMethods is missing', { availableMethods: undefined }], + ])('returns undefined when %s', async (_, override) => { + mockGetInteraction({ authenticationContext: { ...authenticationContext, ...override } }); + + await expect(getStepUpContext()).resolves.toBeUndefined(); + }); + }); + + describe('sendStepUpVerificationCode', () => { + it.each([SignInIdentifier.Email, SignInIdentifier.Phone] as const)( + 'sends the %s verification code by identifier type only and returns the verification ID', + async (type) => { + const response = { verificationId: 'verification-id' }; + const json = jest.fn().mockResolvedValue(response); + mockedApiPost.mockReturnValueOnce({ json } as unknown as ReturnType); + + await expect(sendStepUpVerificationCode(type)).resolves.toEqual(response); + + expect(mockedApiPost).toBeCalledTimes(1); + expect(mockedApiPost).toBeCalledWith( + `${experienceApiRoutes.verification}/verification-code`, + { json: { interactionEvent: InteractionEvent.SignIn, identifier: { type } } } + ); + expect(json).toBeCalledTimes(1); + + const [, options] = mockedApiPost.mock.calls[0] ?? []; + // `toBeCalledWith` uses recursive equality, so pin the exact shape: no identifier value. + expect(options).toStrictEqual({ + json: { interactionEvent: InteractionEvent.SignIn, identifier: { type } }, + }); + } + ); + }); + describe('verifyStepUpPassword', () => { + it('sends the password alone and completes the interaction with the verified record', async () => { + const submitResult = { redirectTo: 'https://logto.io/callback' }; + mockPostResponse({ verificationId: 'password-verification-id' }); + mockedIdentifyAndSubmitInteraction.mockResolvedValueOnce(submitResult); + + await expect(verifyStepUpPassword('password')).resolves.toEqual(submitResult); + + expect(mockedApiPost).toBeCalledTimes(1); + const [url, options] = mockedApiPost.mock.calls[0] ?? []; + expect(url).toBe(`${experienceApiRoutes.verification}/password`); + // Pinned strictly: no identifier, raw or masked, rides along with the password. + expect(options).toStrictEqual({ json: { password: 'password' } }); + expect(mockedIdentifyAndSubmitInteraction).toBeCalledTimes(1); + expect(mockedIdentifyAndSubmitInteraction).toBeCalledWith({ + verificationId: 'password-verification-id', + }); + }); + + it('does not identify or submit when the password is rejected', async () => { + const error = new Error('Invalid credentials'); + const json = jest.fn().mockRejectedValue(error); + mockedApiPost.mockReturnValueOnce({ json } as unknown as ReturnType); + + await expect(verifyStepUpPassword('wrong')).rejects.toThrow(error); + + expect(mockedIdentifyAndSubmitInteraction).not.toBeCalled(); + }); + }); + + describe('verifyStepUpVerificationCode', () => { + it.each([SignInIdentifier.Email, SignInIdentifier.Phone] as const)( + 'verifies the %s code by identifier type only and completes the interaction', + async (type) => { + const submitResult = { redirectTo: 'https://logto.io/callback' }; + mockPostResponse({ verificationId: 'verified-verification-id' }); + mockedIdentifyAndSubmitInteraction.mockResolvedValueOnce(submitResult); + + await expect( + verifyStepUpVerificationCode({ type, code: '123456', verificationId: 'sent-id' }) + ).resolves.toEqual(submitResult); + + expect(mockedApiPost).toBeCalledTimes(1); + const [url, options] = mockedApiPost.mock.calls[0] ?? []; + expect(url).toBe(`${experienceApiRoutes.verification}/verification-code/verify`); + // The raw primary email / phone never leaves the server, on the verify call either. + expect(options).toStrictEqual({ + json: { identifier: { type }, code: '123456', verificationId: 'sent-id' }, + }); + // The record the verify call returns is the one that identifies the pinned subject. + expect(mockedIdentifyAndSubmitInteraction).toBeCalledTimes(1); + expect(mockedIdentifyAndSubmitInteraction).toBeCalledWith({ + verificationId: 'verified-verification-id', + }); + } + ); + + it('does not identify or submit when the code is rejected', async () => { + const error = new Error('Code mismatch'); + const json = jest.fn().mockRejectedValue(error); + mockedApiPost.mockReturnValueOnce({ json } as unknown as ReturnType); + + await expect( + verifyStepUpVerificationCode({ + type: SignInIdentifier.Email, + code: '000000', + verificationId: 'sent-id', + }) + ).rejects.toThrow(error); + + expect(mockedIdentifyAndSubmitInteraction).not.toBeCalled(); + }); + }); +}); diff --git a/packages/experience/src/apis/experience/step-up.ts b/packages/experience/src/apis/experience/step-up.ts new file mode 100644 index 000000000000..aefa04faca37 --- /dev/null +++ b/packages/experience/src/apis/experience/step-up.ts @@ -0,0 +1,105 @@ +import { + InteractionEvent, + type InteractionAuthenticationContext, + interactionAuthenticationContextGuard, +} from '@logto/schemas'; + +import { type VerificationCodeIdentifier } from '@/types'; + +import api from '../api'; + +import { experienceApiRoutes, type VerificationResponse } from './const'; +import { identifyAndSubmitInteraction } from './interaction'; + +type StepUpInitResult = { + /** + * Present when the step-up cannot proceed: Core finished the interaction with + * `unmet_authentication_requirements` and the client is sent back to the application. + */ + redirectTo?: string; +}; + +/** + * Create the step-up interaction with `PUT /experience { interactionEvent: SignIn }`. Core + * detects the step-up from the login prompt details and pins the subject from the OIDC session; + * the SPA sends nothing that identifies the user. + * + * Resolves with `{}` on 204 (the interaction proceeds), or with `{ redirectTo }` on 200. + */ +export const initStepUp = async (): Promise => { + const response = await api.put(experienceApiRoutes.prefix, { + json: { interactionEvent: InteractionEvent.SignIn }, + }); + + return response.status === 200 ? response.json>() : {}; +}; + +type InteractionResponse = { + authenticationContext?: unknown; +}; + +/** + * Read the authoritative step-up state from `GET /experience/interaction`: the selected class, + * the methods that can still contribute to it, and the masked identifiers. Resolves with + * `undefined` when the interaction carries no authentication context or the payload is malformed. + */ +export const getStepUpContext = async (): Promise => { + const { authenticationContext } = await api + .get(experienceApiRoutes.interaction) + .json(); + const result = interactionAuthenticationContextGuard.safeParse(authenticationContext); + + return result.success ? result.data : undefined; +}; + +/** + * Send a verification code to the pinned subject's primary email or phone. Only the identifier + * type is sent: Core resolves the value from the subject, and the raw identifier never reaches + * the browser. + */ +export const sendStepUpVerificationCode = async (type: VerificationCodeIdentifier) => + api + .post(`${experienceApiRoutes.verification}/verification-code`, { + json: { interactionEvent: InteractionEvent.SignIn, identifier: { type } }, + }) + .json(); + +/** + * Verify the pinned subject's password and complete the interaction. + * + * Only the password is sent: Core verifies it against the pinned subject's credential, so no + * identifier — raw or masked — leaves the browser. The verified record then identifies that same + * subject through the existing identification and submission pair. + */ +export const verifyStepUpPassword = async (password: string) => { + const { verificationId } = await api + .post(`${experienceApiRoutes.verification}/password`, { json: { password } }) + .json(); + + return identifyAndSubmitInteraction({ verificationId }); +}; + +type StepUpVerificationCodePayload = { + type: VerificationCodeIdentifier; + code: string; + verificationId: string; +}; + +/** + * Verify a code sent to the pinned subject's primary email or phone, and complete the + * interaction. As when sending it, only the identifier type travels; Core reads the value it + * challenged from the verification record. + */ +export const verifyStepUpVerificationCode = async ({ + type, + code, + verificationId, +}: StepUpVerificationCodePayload) => { + const { verificationId: verifiedVerificationId } = await api + .post(`${experienceApiRoutes.verification}/verification-code/verify`, { + json: { identifier: { type }, code, verificationId }, + }) + .json(); + + return identifyAndSubmitInteraction({ verificationId: verifiedVerificationId }); +}; diff --git a/packages/experience/src/components/Button/StepUpMethodButton.tsx b/packages/experience/src/components/Button/StepUpMethodButton.tsx new file mode 100644 index 000000000000..2d50f60b418d --- /dev/null +++ b/packages/experience/src/components/Button/StepUpMethodButton.tsx @@ -0,0 +1,108 @@ +import { VerificationType } from '@logto/schemas'; +import classNames from 'classnames'; +import { type TFuncKey } from 'i18next'; +import { useTranslation } from 'react-i18next'; + +import FactorBackupCode from '@/assets/icons/factor-backup-code.svg?react'; +import FactorEmail from '@/assets/icons/factor-email.svg?react'; +import FactorPhone from '@/assets/icons/factor-phone.svg?react'; +import FactorTotp from '@/assets/icons/factor-totp.svg?react'; +import FactorWebAuthn from '@/assets/icons/factor-webauthn.svg?react'; +import LockIcon from '@/assets/icons/lock.svg?react'; +import ArrowNext from '@/shared/assets/icons/arrow-next.svg?react'; +import styles from '@/shared/components/Button/index.module.scss'; +import DynamicT from '@/shared/components/DynamicT'; +import FlipOnRtl from '@/shared/components/FlipOnRtl'; +import { type StepUpMethod } from '@/utils/step-up'; + +import factorButtonStyles from './MfaFactorButton.module.scss'; + +export type Props = { + readonly method: StepUpMethod; + /** The masked email or phone the code goes to; shown as the subtitle of a code method. */ + readonly maskedIdentifier?: string; + readonly onClick?: () => void; +}; + +const methodIcon: Record = { + [VerificationType.Password]: LockIcon, + [VerificationType.EmailVerificationCode]: FactorEmail, + [VerificationType.PhoneVerificationCode]: FactorPhone, + [VerificationType.TOTP]: FactorTotp, + [VerificationType.WebAuthn]: FactorWebAuthn, + [VerificationType.BackupCode]: FactorBackupCode, + [VerificationType.MfaEmailVerificationCode]: FactorEmail, + [VerificationType.MfaPhoneVerificationCode]: FactorPhone, +}; + +const methodName: Record = { + [VerificationType.Password]: 'step_up.password', + [VerificationType.EmailVerificationCode]: 'mfa.email_verification_code', + [VerificationType.PhoneVerificationCode]: 'mfa.phone_verification_code', + [VerificationType.TOTP]: 'mfa.totp', + [VerificationType.WebAuthn]: 'mfa.webauthn', + [VerificationType.BackupCode]: 'mfa.backup_code', + [VerificationType.MfaEmailVerificationCode]: 'mfa.email_verification_code', + [VerificationType.MfaPhoneVerificationCode]: 'mfa.phone_verification_code', +}; + +const methodDescription: Record = { + [VerificationType.Password]: 'step_up.password_description', + [VerificationType.EmailVerificationCode]: 'mfa.verify_email_verification_code_description', + [VerificationType.PhoneVerificationCode]: 'mfa.verify_phone_verification_code_description', + [VerificationType.TOTP]: 'mfa.verify_totp_description', + [VerificationType.WebAuthn]: 'mfa.verify_webauthn_description', + [VerificationType.BackupCode]: 'mfa.verify_backup_code_description', + [VerificationType.MfaEmailVerificationCode]: 'mfa.verify_email_verification_code_description', + [VerificationType.MfaPhoneVerificationCode]: 'mfa.verify_phone_verification_code_description', +}; + +const emailCodeMethods: ReadonlySet = new Set([ + VerificationType.EmailVerificationCode, + VerificationType.MfaEmailVerificationCode, +]); + +/** + * One entry of the step-up method list, modeled on `MfaFactorButton` (whose styles it shares) and + * keyed by the verification types Core can offer for step-up. + */ +const StepUpMethodButton = ({ method, maskedIdentifier, onClick }: Props) => { + const { t } = useTranslation(); + const Icon = methodIcon[method]; + + return ( + + ); +}; + +export default StepUpMethodButton; diff --git a/packages/experience/src/constants/step-up.ts b/packages/experience/src/constants/step-up.ts new file mode 100644 index 000000000000..bdfbe39440b3 --- /dev/null +++ b/packages/experience/src/constants/step-up.ts @@ -0,0 +1,39 @@ +import { type LogtoErrorCode } from '@logto/phrases'; +import { experience } from '@logto/schemas'; + +import { type VerificationCodeIdentifier } from '@/types'; + +/** + * The pages of the `/step-up` route tree. The landing runs the server-driven dispatch, and the + * pinned-user first-factor pages verify the pinned subject's password or primary email / phone + * code. A sign-in with requested ACR reaches the same pages through the landing, which + * `session.step_up.require_verification` navigates to. + */ +export const stepUpRoutes = Object.freeze({ + landing: `/${experience.routes.stepUp}`, + password: `/${experience.routes.stepUp}/password`, + verificationCode: `/${experience.routes.stepUp}/verification-code`, +}); + +/** + * The pinned-user code page of one identifier type. The type rides in the path rather than in + * `location.state`, so a refresh recovers the page the user is on; the code itself is addressed + * by the verification ID the send stored, and the raw identifier never appears here. + */ +export const getStepUpVerificationCodeRoute = (type: VerificationCodeIdentifier) => + `${stepUpRoutes.verificationCode}/${type}`; + +/** The route that renders the invalid-session error page (registered in `App.tsx`). */ +export const unknownSessionRoute = '/unknown-session'; + +/** + * The errors that mean the OIDC interaction, the step-up interaction, or the subject it pins is + * gone. Nothing on the client can recover from them: the guard and the step-up error handlers + * both land on the invalid-session page, and the guard shows no toast for them. + */ +export const stepUpSessionGoneErrorCodes = Object.freeze([ + 'session.not_found', + 'session.interaction_not_found', + 'session.step_up.subject_not_found', + 'session.step_up.invalid_interaction_event', +] as const satisfies readonly LogtoErrorCode[]); diff --git a/packages/experience/src/containers/StepUpCodeVerification/index.module.scss b/packages/experience/src/containers/StepUpCodeVerification/index.module.scss new file mode 100644 index 000000000000..60452550998b --- /dev/null +++ b/packages/experience/src/containers/StepUpCodeVerification/index.module.scss @@ -0,0 +1,19 @@ +@use '@/shared/scss/underscore' as _; + +.codeInput { + margin-top: _.unit(4); +} + +.continueButton { + margin-top: _.unit(6); +} + +.message { + margin-top: _.unit(3); + font: var(--font-body-2); + color: var(--color-type-secondary); +} + +.link { + cursor: pointer; +} diff --git a/packages/experience/src/containers/StepUpCodeVerification/index.test.tsx b/packages/experience/src/containers/StepUpCodeVerification/index.test.tsx new file mode 100644 index 000000000000..464038f82755 --- /dev/null +++ b/packages/experience/src/containers/StepUpCodeVerification/index.test.tsx @@ -0,0 +1,289 @@ +import { SignInIdentifier, VerificationType } from '@logto/schemas'; +import { noop } from '@silverhand/essentials'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { cloneElement, type ReactElement, type ReactNode } from 'react'; + +import UserInteractionContext, { + type UserInteractionContextType, +} from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import { sendStepUpVerificationCode, verifyStepUpVerificationCode } from '@/apis/experience'; +import { type ErrorHandlers } from '@/hooks/use-error-handler'; +import { type VerificationCodeIdentifier } from '@/types'; + +import StepUpCodeVerification from '.'; + +const mockedHandleError = jest.fn, [unknown, ErrorHandlers?]>(); +const mockedRedirectTo = jest.fn(); +const mockedNavigate = jest.fn(); +const mockedSetToast = jest.fn(); +const mockedSetVerificationId = jest.fn(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { dir: () => 'ltr' }, + }), + // Render the interpolated component, so the resend link keeps its click handler. + Trans: ({ + children, + components, + }: { + readonly children: ReactNode; + readonly components?: Record; + }) => (components?.a ? cloneElement(components.a, {}, children) : children), +})); + +jest.mock('@/hooks/use-navigate-with-preserved-search-params', () => ({ + ...jest.requireActual('@/hooks/use-navigate-with-preserved-search-params'), + __esModule: true, + default: () => mockedNavigate, +})); + +jest.mock('@/hooks/use-error-handler', () => ({ + __esModule: true, + default: () => mockedHandleError, +})); + +jest.mock('@/hooks/use-global-redirect-to', () => ({ + __esModule: true, + default: () => mockedRedirectTo, +})); + +jest.mock('@/hooks/use-toast', () => ({ + __esModule: true, + default: () => ({ setToast: mockedSetToast }), +})); + +/** + * The shared code input is exercised by its own tests; here it only has to report a code, so the + * container's own behavior — what it sends, and against which record — is what the tests read. + */ +jest.mock('@/shared/components/VerificationCode', () => ({ + __esModule: true, + defaultLength: 6, + default: ({ + error, + onChange, + }: { + readonly error?: string; + readonly onChange: (code: string[]) => void; + }) => ( + <> + + + {error &&
{error}
} + + ), +})); + +/** The resend countdown starts on mount; stop it so the resend link is always reachable. */ +jest.mock('react-timer-hook', () => ({ + useTimer: () => ({ seconds: 0, isRunning: false, restart: jest.fn() }), +})); + +jest.mock('@/apis/experience', () => ({ + ...jest.requireActual('@/apis/experience'), + sendStepUpVerificationCode: jest.fn(), + verifyStepUpVerificationCode: jest.fn(), +})); + +const mockedSendStepUpVerificationCode = sendStepUpVerificationCode as jest.MockedFunction< + typeof sendStepUpVerificationCode +>; +const mockedVerifyStepUpVerificationCode = verifyStepUpVerificationCode as jest.MockedFunction< + typeof verifyStepUpVerificationCode +>; + +/** The codes `useStepUpErrorHandler` contributes; every step-up call composes them. */ +const stepUpErrorCodes = [ + 'session.not_found', + 'session.interaction_not_found', + 'session.step_up.subject_not_found', + 'session.step_up.invalid_interaction_event', + 'session.identity_conflict', + 'session.step_up.forbidden_route', + 'session.step_up.acr_not_satisfied', +]; + +const userInteractionContext: UserInteractionContextType = { + availableSsoConnectorsMap: new Map(), + setSsoEmail: noop, + ssoConnectors: [], + setSsoConnectors: noop, + setIdentifierInputValue: noop, + setForgotPasswordIdentifierInputValue: noop, + setVerificationId: mockedSetVerificationId, + verificationIdsMap: {}, + hasBoundPasskey: false, + setHasBoundPasskey: noop, + clearInteractionContextSessionStorage: noop, +}; + +const renderContainer = (identifierType: VerificationCodeIdentifier = SignInIdentifier.Email) => + renderWithPageContext( + + + + ); + +const enterCode = async (label = 'Fill code') => { + await act(async () => { + fireEvent.click(screen.getByText(label)); + }); +}; + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('offers no switch to another identifier or to a password', () => { + renderContainer(); + + // The method list is where the user switches; this page is pinned to one identifier. + expect(screen.queryByText('action.sign_in_via_password')).toBeNull(); + expect(screen.queryByText('action.sign_in_via_passcode')).toBeNull(); + }); + + it.each([SignInIdentifier.Email, SignInIdentifier.Phone] as const)( + 'verifies the %s code by identifier type only and follows the redirect', + async (identifierType) => { + mockedVerifyStepUpVerificationCode.mockResolvedValueOnce({ + redirectTo: 'https://logto.io/callback', + }); + renderContainer(identifierType); + + await enterCode(); + + await waitFor(() => { + expect(mockedRedirectTo).toHaveBeenCalledWith('https://logto.io/callback'); + }); + expect(mockedVerifyStepUpVerificationCode).toHaveBeenCalledTimes(1); + // No raw identifier: the type and the verification ID are the whole payload. + expect(mockedVerifyStepUpVerificationCode).toHaveBeenCalledWith({ + type: identifierType, + code: '123456', + verificationId: 'sent-id', + }); + expect(mockedHandleError).not.toHaveBeenCalled(); + } + ); + + it('shows an input error and does not verify an incomplete code', async () => { + renderContainer(); + + await enterCode('Fill partial code'); + + await act(async () => { + fireEvent.click(screen.getByText('action.continue')); + }); + + expect(mockedVerifyStepUpVerificationCode).not.toHaveBeenCalled(); + expect(screen.getByText('error.invalid_passcode')).not.toBeNull(); + }); + + it('hands a failed verification to the error handler with the step-up handlers', async () => { + const error = new Error('Code mismatch'); + mockedVerifyStepUpVerificationCode.mockRejectedValueOnce(error); + renderContainer(); + + await enterCode(); + + await waitFor(() => { + expect(mockedHandleError).toHaveBeenCalledTimes(1); + }); + + const [handledError, errorHandlers] = mockedHandleError.mock.calls[0] ?? []; + expect(handledError).toBe(error); + expect(Object.keys(errorHandlers ?? {})).toEqual( + expect.arrayContaining([ + // A wrong or expired code is shown on the input rather than toasted. + 'verification_code.code_mismatch', + 'verification_code.expired', + ...stepUpErrorCodes, + // What a sign-in with requested ACR may still need after this factor. + 'session.mfa.require_mfa_verification', + 'user.missing_profile', + ]) + ); + expect(mockedRedirectTo).not.toHaveBeenCalled(); + }); + + it.each([SignInIdentifier.Email, SignInIdentifier.Phone] as const)( + 'resends the %s code by identifier type only and verifies against the new record', + async (identifierType) => { + mockedSendStepUpVerificationCode.mockResolvedValueOnce({ verificationId: 'resent-id' }); + mockedVerifyStepUpVerificationCode.mockResolvedValueOnce({ + redirectTo: 'https://logto.io/callback', + }); + renderContainer(identifierType); + + await act(async () => { + fireEvent.click(screen.getByText('description.resend_passcode')); + }); + + expect(mockedSendStepUpVerificationCode).toHaveBeenCalledTimes(1); + expect(mockedSendStepUpVerificationCode).toHaveBeenCalledWith(identifierType); + // The stored id is renewed, so a refresh verifies against the code that was sent last. + expect(mockedSetVerificationId).toHaveBeenCalledWith( + identifierType === SignInIdentifier.Email + ? VerificationType.EmailVerificationCode + : VerificationType.PhoneVerificationCode, + 'resent-id' + ); + expect(mockedSetToast).toHaveBeenCalledWith('description.passcode_sent'); + + await enterCode(); + + await waitFor(() => { + expect(mockedVerifyStepUpVerificationCode).toHaveBeenCalledWith({ + type: identifierType, + code: '123456', + verificationId: 'resent-id', + }); + }); + } + ); + + it('hands a failed resend to the error handler with the step-up handlers and keeps the old id', async () => { + const error = new Error('Failed to send the code'); + mockedSendStepUpVerificationCode.mockRejectedValueOnce(error); + renderContainer(); + + await act(async () => { + fireEvent.click(screen.getByText('description.resend_passcode')); + }); + + expect(mockedHandleError).toHaveBeenCalledTimes(1); + const [handledError, errorHandlers] = mockedHandleError.mock.calls[0] ?? []; + expect(handledError).toBe(error); + expect(new Set(Object.keys(errorHandlers ?? {}))).toEqual(new Set(stepUpErrorCodes)); + expect(mockedSetVerificationId).not.toHaveBeenCalled(); + + mockedVerifyStepUpVerificationCode.mockResolvedValueOnce({ redirectTo: 'https://logto.io' }); + await enterCode(); + + await waitFor(() => { + expect(mockedVerifyStepUpVerificationCode).toHaveBeenCalledWith({ + type: SignInIdentifier.Email, + code: '123456', + verificationId: 'sent-id', + }); + }); + }); +}); diff --git a/packages/experience/src/containers/StepUpCodeVerification/index.tsx b/packages/experience/src/containers/StepUpCodeVerification/index.tsx new file mode 100644 index 000000000000..baeb7ff9383d --- /dev/null +++ b/packages/experience/src/containers/StepUpCodeVerification/index.tsx @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import TextLink from '@/components/TextLink'; +import Button from '@/shared/components/Button'; +import VerificationCodeInput, { defaultLength } from '@/shared/components/VerificationCode'; +import { type VerificationCodeIdentifier } from '@/types'; + +import styles from './index.module.scss'; +import useResendStepUpVerificationCode from './use-resend-step-up-verification-code'; +import useStepUpCodeVerification from './use-step-up-code-verification'; + +const isCodeReady = (code: string[]) => code.length === defaultLength && code.every(Boolean); + +type Props = { + readonly identifierType: VerificationCodeIdentifier; + readonly verificationId: string; +}; + +/** + * The pinned-user code input, modeled on `MfaCodeVerification`: the code goes to an identifier + * the server resolved, so the container takes its type and nothing else. There is no link back to + * a password page or to another identifier — the method list is where the user switches. + */ +const StepUpCodeVerification = ({ identifierType, verificationId }: Props) => { + const { t } = useTranslation(); + const [codeInput, setCodeInput] = useState([]); + const [inputErrorMessage, setInputErrorMessage] = useState(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [currentVerificationId, setCurrentVerificationId] = useState(verificationId); + + useEffect(() => { + setCurrentVerificationId(verificationId); + }, [verificationId]); + + const errorCallback = useCallback(() => { + setCodeInput([]); + setInputErrorMessage(undefined); + }, []); + + const { errorMessage: submitErrorMessage, onSubmit } = useStepUpCodeVerification( + identifierType, + currentVerificationId, + errorCallback + ); + + const { seconds, isRunning, onResendVerificationCode } = + useResendStepUpVerificationCode(identifierType); + + const errorMessage = inputErrorMessage ?? submitErrorMessage; + + const handleSubmit = useCallback( + async (code: string[]) => { + if (isSubmitting) { + return; + } + + setInputErrorMessage(undefined); + setIsSubmitting(true); + + try { + await onSubmit(code.join('')); + } finally { + // Always reset, even if `onSubmit` throws, so the button does not spin forever. + setIsSubmitting(false); + } + }, + [isSubmitting, onSubmit] + ); + + return ( + <> + { + setCodeInput(code); + + if (isCodeReady(code)) { + void handleSubmit(code); + } + }} + /> +
+ {isRunning ? ( + }}> + {t('description.resend_after_seconds', { seconds })} + + ) : ( + { + setInputErrorMessage(undefined); + setCodeInput([]); + + const resentVerificationId = await onResendVerificationCode(); + + if (resentVerificationId) { + setCurrentVerificationId(resentVerificationId); + } + }} + /> + ), + }} + > + {t('description.resend_passcode')} + + )} +
+ + + ); +}; + +const renderGuard = (initialEntry: string) => + renderWithPageContext( + + }> + } /> + } /> + + , + { initialEntries: [initialEntry] } + ); + +describe('StepUpGuard', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('landing route', () => { + it('creates the interaction only after a 404 interaction_not_found, then re-reads the context', async () => { + mockedGetStepUpContext + .mockRejectedValueOnce(createRequestError('session.interaction_not_found')) + .mockResolvedValueOnce(stepUpContext); + mockedInitStepUp.mockResolvedValue({}); + + renderGuard(stepUpRoutes.landing); + + expect(await screen.findByText('landing:Password,EmailVerificationCode')).not.toBeNull(); + + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(2); + expect(mockedInitStepUp).toHaveBeenCalledTimes(1); + expect(mockedInitStepUp).toHaveBeenCalledWith(); + + // Call order: GET, PUT, GET. + const [firstGet, secondGet] = mockedGetStepUpContext.mock.invocationCallOrder; + const [put] = mockedInitStepUp.mock.invocationCallOrder; + expect(firstGet).toBeLessThan(put ?? 0); + expect(put).toBeLessThan(secondGet ?? 0); + + expect(mockHandleError).not.toHaveBeenCalled(); + expect(mockRedirectTo).not.toHaveBeenCalled(); + }); + + it('keeps the existing interaction on refresh and never calls initStepUp', async () => { + mockedGetStepUpContext.mockResolvedValue(stepUpContext); + + renderGuard(stepUpRoutes.landing); + + expect(await screen.findByText('landing:Password,EmailVerificationCode')).not.toBeNull(); + + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(1); + expect(mockedInitStepUp).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('renders the invalid-session page when the interaction carries no authentication context', async () => { + mockedGetStepUpContext.mockResolvedValue(undefined); + + renderGuard(stepUpRoutes.landing); + + expect(await screen.findByText('error.invalid_session')).not.toBeNull(); + + expect(screen.queryByText(/^landing:/)).toBeNull(); + expect(mockedInitStepUp).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('follows the redirectTo returned by initStepUp without reading the context again', async () => { + const redirectUrl = 'https://app.example/callback?error=unmet_authentication_requirements'; + mockedGetStepUpContext.mockRejectedValueOnce( + createRequestError('session.interaction_not_found') + ); + mockedInitStepUp.mockResolvedValue({ redirectTo: redirectUrl }); + + renderGuard(stepUpRoutes.landing); + + await waitFor(() => { + expect(mockRedirectTo).toHaveBeenCalledWith(redirectUrl); + }); + + expect(mockRedirectTo).toHaveBeenCalledTimes(1); + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(1); + expect(mockedInitStepUp).toHaveBeenCalledTimes(1); + // The redirect unloads the page: neither the landing nor the error page is shown. + expect(screen.queryByText(/^landing:/)).toBeNull(); + expect(screen.queryByText('error.invalid_session')).toBeNull(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it.each([ + ['a 500 with a Logto error code', createRequestError('guard.invalid_input', 500)], + ['a plain Error', new Error('network down')], + ])( + 'surfaces %s through the error handler and renders the invalid-session page', + async (_, error) => { + mockedGetStepUpContext.mockRejectedValue(error); + + renderGuard(stepUpRoutes.landing); + + expect(await screen.findByText('error.invalid_session')).not.toBeNull(); + + expect(mockHandleError).toHaveBeenCalledTimes(1); + expect(mockHandleError).toHaveBeenCalledWith(error); + // Only `session.interaction_not_found` creates the interaction. + expect(mockedInitStepUp).not.toHaveBeenCalled(); + expect(mockRedirectTo).not.toHaveBeenCalled(); + } + ); + + it('does not create the interaction or toast when the subject is gone', async () => { + mockedGetStepUpContext.mockRejectedValue( + createRequestError('session.step_up.subject_not_found') + ); + + renderGuard(stepUpRoutes.landing); + + expect(await screen.findByText('error.invalid_session')).not.toBeNull(); + + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(1); + expect(mockedInitStepUp).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('surfaces a failed initStepUp through the error handler', async () => { + const initError = createRequestError('guard.invalid_input', 500); + mockedGetStepUpContext.mockRejectedValueOnce( + createRequestError('session.interaction_not_found') + ); + mockedInitStepUp.mockRejectedValue(initError); + + renderGuard(stepUpRoutes.landing); + + expect(await screen.findByText('error.invalid_session')).not.toBeNull(); + + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(1); + expect(mockedInitStepUp).toHaveBeenCalledTimes(1); + expect(mockHandleError).toHaveBeenCalledWith(initError); + expect(mockRedirectTo).not.toHaveBeenCalled(); + }); + }); + + describe('child route', () => { + it('reads the context once and never calls initStepUp', async () => { + mockedGetStepUpContext.mockResolvedValue(stepUpContext); + + renderGuard(stepUpRoutes.password); + + expect(await screen.findByText('child:Password,EmailVerificationCode')).not.toBeNull(); + + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(1); + expect(mockedInitStepUp).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('renders the invalid-session page without a toast when the interaction is gone', async () => { + mockedGetStepUpContext.mockRejectedValue(createRequestError('session.interaction_not_found')); + + renderGuard(stepUpRoutes.password); + + expect(await screen.findByText('error.invalid_session')).not.toBeNull(); + + expect(screen.queryByText(/^child:/)).toBeNull(); + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(1); + // A child route never creates the interaction, even on 404. + expect(mockedInitStepUp).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('re-reads the context on refetch while keeping the page mounted', async () => { + mockedGetStepUpContext + .mockResolvedValueOnce(stepUpContext) + .mockResolvedValueOnce(refreshedStepUpContext); + + renderGuard(stepUpRoutes.password); + + expect(await screen.findByText('child:Password,EmailVerificationCode')).not.toBeNull(); + + fireEvent.click(screen.getByText('refetch')); + + expect(await screen.findByText('child:Password')).not.toBeNull(); + + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(2); + expect(mockedInitStepUp).not.toHaveBeenCalled(); + expect(screen.queryByText('error.invalid_session')).toBeNull(); + // The guard kept the page mounted through the refetch instead of remounting it. + expect(mockChildMount).toHaveBeenCalledTimes(1); + }); + + it('loads the context again when navigating back to the landing', async () => { + mockedGetStepUpContext + .mockResolvedValueOnce(stepUpContext) + .mockResolvedValueOnce(refreshedStepUpContext); + + renderGuard(stepUpRoutes.password); + + expect(await screen.findByText('child:Password,EmailVerificationCode')).not.toBeNull(); + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText('back to landing')); + + expect(await screen.findByText('landing:Password')).not.toBeNull(); + + // A new arrival at the landing is loading from its very first render, even though the + // context of the previous visit is still present: it never rendered the stale methods. + const landingRenders = mockLandingRender.mock.calls.map(([text]) => text); + expect(landingRenders[0]).toBe('landing:loading'); + expect(landingRenders).not.toContain('landing:Password,EmailVerificationCode'); + + expect(mockedGetStepUpContext).toHaveBeenCalledTimes(2); + expect(mockedInitStepUp).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/experience/src/containers/StepUpGuard/index.tsx b/packages/experience/src/containers/StepUpGuard/index.tsx new file mode 100644 index 000000000000..4935f3c057bf --- /dev/null +++ b/packages/experience/src/containers/StepUpGuard/index.tsx @@ -0,0 +1,32 @@ +import { Outlet } from 'react-router-dom'; + +import StepUpContextProvider from '@/Providers/StepUpContextProvider'; +import useStepUpContext from '@/hooks/use-step-up-context'; +import ErrorPage from '@/pages/ErrorPage'; + +const StepUpOutlet = () => { + const { authenticationContext, isLoading } = useStepUpContext(); + + if (!authenticationContext) { + // The interaction is gone (404 `session.interaction_not_found`), or it carries no + // authentication context: neither can enter the route tree. This is the page the + // `unknown-session` route renders, where the step-up error handlers also land. + return isLoading ? null : ; + } + + // A later refetch keeps the current page mounted; the pages read the fresh context themselves. + return ; +}; + +/** + * The route wrapper of the `/step-up` tree: loads the server-driven context and renders the + * invalid-session page when the interaction is gone or carries no `authenticationContext`. This + * is the server-driven analogue of the `useMfaFlowState` guard on the MFA pages. + */ +const StepUpGuard = () => ( + + + +); + +export default StepUpGuard; diff --git a/packages/experience/src/containers/StepUpMethodList/index.module.scss b/packages/experience/src/containers/StepUpMethodList/index.module.scss new file mode 100644 index 000000000000..27e4c394b7d8 --- /dev/null +++ b/packages/experience/src/containers/StepUpMethodList/index.module.scss @@ -0,0 +1,6 @@ +@use '@/shared/scss/underscore' as _; + +.methodList { + @include _.flex-column; + gap: _.unit(3); +} diff --git a/packages/experience/src/containers/StepUpMethodList/index.test.tsx b/packages/experience/src/containers/StepUpMethodList/index.test.tsx new file mode 100644 index 000000000000..54c224f59735 --- /dev/null +++ b/packages/experience/src/containers/StepUpMethodList/index.test.tsx @@ -0,0 +1,433 @@ +import { + MfaFactor, + type RequestErrorBody, + SignInIdentifier, + VerificationType, +} from '@logto/schemas'; +import { noop } from '@silverhand/essentials'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; + +import UserInteractionContext, { + type UserInteractionContextType, +} from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import { sendStepUpVerificationCode } from '@/apis/experience'; +import { type ErrorHandlers } from '@/hooks/use-error-handler'; +import type useSendMfaVerificationCode from '@/hooks/use-send-mfa-verification-code'; +import type useStartWebAuthnProcessing from '@/hooks/use-start-webauthn-processing'; +import { UserMfaFlow } from '@/types'; +import { type MfaFlowState } from '@/types/guard'; +import { type StepUpMethod } from '@/utils/step-up'; + +import StepUpMethodList from '.'; + +const mockedNavigate = jest.fn(); +const mockedHandleError = jest.fn, [unknown, ErrorHandlers?]>(); +const mockedSetVerificationId = jest.fn(); +const mockedStartWebAuthnProcessing = jest.fn(); +const mockedSendMfaVerificationCode = jest.fn(); +const mockedUseStartWebAuthnProcessing = jest.fn< + void, + Parameters +>(); +const mockedUseSendMfaVerificationCode = jest.fn< + void, + Parameters +>(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + // Surface the interpolation options so the masked identifier can be asserted on. + t: (key: string, options?: Record) => + options && Object.keys(options).length > 0 ? `${key}:${JSON.stringify(options)}` : key, + i18n: { dir: () => 'ltr' }, + }), +})); + +jest.mock('@/hooks/use-navigate-with-preserved-search-params', () => ({ + __esModule: true, + default: () => mockedNavigate, +})); + +jest.mock('@/hooks/use-error-handler', () => ({ + __esModule: true, + default: () => mockedHandleError, +})); + +jest.mock('@/apis/experience', () => ({ + ...jest.requireActual('@/apis/experience'), + sendStepUpVerificationCode: jest.fn(), +})); + +jest.mock('@/hooks/use-start-webauthn-processing', () => ({ + __esModule: true, + default: (...args: Parameters) => { + mockedUseStartWebAuthnProcessing(...args); + return mockedStartWebAuthnProcessing; + }, +})); + +jest.mock('@/hooks/use-send-mfa-verification-code', () => ({ + __esModule: true, + default: (...args: Parameters) => { + mockedUseSendMfaVerificationCode(...args); + return { onSubmit: mockedSendMfaVerificationCode }; + }, +})); + +const mockedSendStepUpVerificationCode = sendStepUpVerificationCode as jest.MockedFunction< + typeof sendStepUpVerificationCode +>; + +const email = 'f***@logto.io'; +const phone = '+1******1234'; +const bothIdentifiers = { email, phone }; +const verificationId = 'vid'; + +/** The error codes `useStepUpErrorHandler` handles; every step-up call composes them. */ +const stepUpErrorCodes = new Set([ + 'session.not_found', + 'session.interaction_not_found', + 'session.step_up.subject_not_found', + 'session.step_up.invalid_interaction_event', + 'session.identity_conflict', + 'session.step_up.forbidden_route', + 'session.step_up.acr_not_satisfied', +]); + +const userInteractionContext: UserInteractionContextType = { + availableSsoConnectorsMap: new Map(), + setSsoEmail: noop, + ssoConnectors: [], + setSsoConnectors: noop, + setIdentifierInputValue: noop, + setForgotPasswordIdentifierInputValue: noop, + verificationIdsMap: {}, + setVerificationId: mockedSetVerificationId, + clearInteractionContextSessionStorage: noop, + hasBoundPasskey: false, + setHasBoundPasskey: noop, +}; + +/** Every method family, in the canonical server order. */ +const allMethods: StepUpMethod[] = [ + VerificationType.Password, + VerificationType.EmailVerificationCode, + VerificationType.PhoneVerificationCode, + VerificationType.TOTP, + VerificationType.WebAuthn, + VerificationType.BackupCode, + VerificationType.MfaEmailVerificationCode, + VerificationType.MfaPhoneVerificationCode, +]; + +/** The pinned-user first factors; their name keys are unique within this list. */ +const firstFactorMethods: StepUpMethod[] = [ + VerificationType.Password, + VerificationType.EmailVerificationCode, + VerificationType.PhoneVerificationCode, +]; + +/** A first factor plus every enrolled MFA factor; the MFA code name keys are unique here. */ +const mfaMethods: StepUpMethod[] = [ + VerificationType.Password, + VerificationType.TOTP, + VerificationType.WebAuthn, + VerificationType.BackupCode, + VerificationType.MfaEmailVerificationCode, + VerificationType.MfaPhoneVerificationCode, +]; + +/** What the MFA pages receive when {@link mfaMethods} are displayed with both identifiers. */ +const mfaFlowState: MfaFlowState = { + availableFactors: [ + MfaFactor.TOTP, + MfaFactor.WebAuthn, + MfaFactor.BackupCode, + MfaFactor.EmailVerificationCode, + MfaFactor.PhoneVerificationCode, + ], + maskedIdentifiers: { + [MfaFactor.EmailVerificationCode]: email, + [MfaFactor.PhoneVerificationCode]: phone, + }, +}; + +/** The text of one button: its name key followed by its description. */ +const label = (name: string, description: string) => `${name}${description}`; + +/** The subtitle of a code method, as the mocked `t` renders the interpolated identifier. */ +const sendTo = (key: 'mfa.send_to_email' | 'mfa.send_to_phone', identifier: string) => + `${key}:${JSON.stringify({ identifier })}`; + +const renderList = ( + methods: readonly StepUpMethod[], + maskedIdentifiers: { email?: string; phone?: string } = bothIdentifiers +) => + renderWithPageContext( + + + + ); + +/** The rendered text of every button, in DOM order. */ +const getRenderedButtons = () => screen.getAllByRole('button').map((button) => button.textContent); + +const clickMethod = async (nameKey: string) => { + await act(async () => { + fireEvent.click(screen.getByText(nameKey)); + }); +}; + +describe('StepUpMethodList', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedSendStepUpVerificationCode.mockResolvedValue({ verificationId }); + }); + + describe('rendering', () => { + it('renders one button per method in the given order with its name and description', () => { + renderList(allMethods, {}); + + expect(getRenderedButtons()).toEqual([ + label('step_up.password', 'step_up.password_description'), + label('mfa.email_verification_code', 'mfa.verify_email_verification_code_description'), + label('mfa.phone_verification_code', 'mfa.verify_phone_verification_code_description'), + label('mfa.totp', 'mfa.verify_totp_description'), + label('mfa.webauthn', 'mfa.verify_webauthn_description'), + label('mfa.backup_code', 'mfa.verify_backup_code_description'), + label('mfa.email_verification_code', 'mfa.verify_email_verification_code_description'), + label('mfa.phone_verification_code', 'mfa.verify_phone_verification_code_description'), + ]); + }); + + it('keeps the server order rather than the canonical one', () => { + renderList([VerificationType.BackupCode, VerificationType.Password], {}); + + expect(getRenderedButtons()).toEqual([ + label('mfa.backup_code', 'mfa.verify_backup_code_description'), + label('step_up.password', 'step_up.password_description'), + ]); + }); + + it('renders nothing when no method is given', () => { + renderList([]); + + expect(screen.queryAllByRole('button')).toHaveLength(0); + }); + + it('shows the masked identifier as the subtitle of the code methods', () => { + renderList(allMethods); + + expect(getRenderedButtons()).toEqual([ + label('step_up.password', 'step_up.password_description'), + label('mfa.email_verification_code', sendTo('mfa.send_to_email', email)), + label('mfa.phone_verification_code', sendTo('mfa.send_to_phone', phone)), + label('mfa.totp', 'mfa.verify_totp_description'), + label('mfa.webauthn', 'mfa.verify_webauthn_description'), + label('mfa.backup_code', 'mfa.verify_backup_code_description'), + label('mfa.email_verification_code', sendTo('mfa.send_to_email', email)), + label('mfa.phone_verification_code', sendTo('mfa.send_to_phone', phone)), + ]); + expect(screen.queryByText('mfa.verify_email_verification_code_description')).toBeNull(); + expect(screen.queryByText('mfa.verify_phone_verification_code_description')).toBeNull(); + }); + + it('falls back to the generic description of a code method without an identifier', () => { + renderList( + [ + VerificationType.EmailVerificationCode, + VerificationType.MfaEmailVerificationCode, + VerificationType.PhoneVerificationCode, + VerificationType.MfaPhoneVerificationCode, + ], + { phone } + ); + + expect(getRenderedButtons()).toEqual([ + label('mfa.email_verification_code', 'mfa.verify_email_verification_code_description'), + label('mfa.email_verification_code', 'mfa.verify_email_verification_code_description'), + label('mfa.phone_verification_code', sendTo('mfa.send_to_phone', phone)), + label('mfa.phone_verification_code', sendTo('mfa.send_to_phone', phone)), + ]); + expect(screen.queryByText(/^mfa\.send_to_email/)).toBeNull(); + }); + }); + + describe('selecting a first factor', () => { + it('opens the step-up password page for Password', async () => { + renderList(firstFactorMethods); + + await clickMethod('step_up.password'); + + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate).toHaveBeenCalledWith('/step-up/password', { replace: undefined }); + expect(mockedSendStepUpVerificationCode).not.toHaveBeenCalled(); + expect(mockedSetVerificationId).not.toHaveBeenCalled(); + }); + + it.each([ + { + method: VerificationType.EmailVerificationCode, + nameKey: 'mfa.email_verification_code', + identifier: SignInIdentifier.Email, + route: '/step-up/verification-code/email', + }, + { + method: VerificationType.PhoneVerificationCode, + nameKey: 'mfa.phone_verification_code', + identifier: SignInIdentifier.Phone, + route: '/step-up/verification-code/phone', + }, + ])( + 'sends the pinned-user code by type only, stores its id, then opens the $identifier code page', + async ({ method, nameKey, identifier, route }) => { + renderList(firstFactorMethods); + + await clickMethod(nameKey); + + await waitFor(() => { + expect(mockedNavigate).toHaveBeenCalledWith(route, { replace: undefined }); + }); + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedSendStepUpVerificationCode).toHaveBeenCalledTimes(1); + expect(mockedSendStepUpVerificationCode).toHaveBeenCalledWith(identifier); + expect(mockedSetVerificationId).toHaveBeenCalledTimes(1); + expect(mockedSetVerificationId).toHaveBeenCalledWith(method, verificationId); + // The next page reads the id from the context, so it must be stored before navigating. + const [storedAt] = mockedSetVerificationId.mock.invocationCallOrder; + const [navigatedAt] = mockedNavigate.mock.invocationCallOrder; + expect(storedAt).toBeLessThan(navigatedAt ?? Number.NaN); + expect(mockedHandleError).not.toHaveBeenCalled(); + } + ); + + it('hands a failed code request to the error handler with the step-up handlers and stays', async () => { + const error = new Error('Failed to send the code'); + mockedSendStepUpVerificationCode.mockRejectedValueOnce(error); + renderList(firstFactorMethods); + + await clickMethod('mfa.email_verification_code'); + + await waitFor(() => { + expect(mockedHandleError).toHaveBeenCalledTimes(1); + }); + const [handledError, errorHandlers] = mockedHandleError.mock.calls[0] ?? []; + expect(handledError).toBe(error); + expect(new Set(Object.keys(errorHandlers ?? {}))).toEqual(stepUpErrorCodes); + expect(mockedNavigate).not.toHaveBeenCalled(); + expect(mockedSetVerificationId).not.toHaveBeenCalled(); + + // A gone session is handled by the step-up handlers, not by the default toast. + await errorHandlers?.['session.interaction_not_found']?.({ + code: 'session.interaction_not_found', + message: 'Interaction not found.', + data: {}, + }); + + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate).toHaveBeenCalledWith('/unknown-session', { replace: true }); + }); + }); + + describe('selecting an MFA factor', () => { + it.each([ + { nameKey: 'mfa.totp', factor: MfaFactor.TOTP }, + { nameKey: 'mfa.backup_code', factor: MfaFactor.BackupCode }, + ])( + 'opens the MFA verification page of $factor with the flow state of the displayed methods', + async ({ nameKey, factor }) => { + renderList(mfaMethods); + + await clickMethod(nameKey); + + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate).toHaveBeenCalledWith(`/${UserMfaFlow.MfaVerification}/${factor}`, { + replace: undefined, + state: mfaFlowState, + }); + expect(mockedStartWebAuthnProcessing).not.toHaveBeenCalled(); + expect(mockedSendMfaVerificationCode).not.toHaveBeenCalled(); + expect(mockedSendStepUpVerificationCode).not.toHaveBeenCalled(); + } + ); + + it('starts the WebAuthn verification ceremony with the flow state', async () => { + renderList(mfaMethods); + + await clickMethod('mfa.webauthn'); + + expect(mockedStartWebAuthnProcessing).toHaveBeenCalledTimes(1); + expect(mockedStartWebAuthnProcessing).toHaveBeenCalledWith( + UserMfaFlow.MfaVerification, + mfaFlowState, + undefined + ); + expect(mockedNavigate).not.toHaveBeenCalled(); + expect(mockedSendMfaVerificationCode).not.toHaveBeenCalled(); + }); + + it.each([ + { nameKey: 'mfa.email_verification_code', identifier: SignInIdentifier.Email }, + { nameKey: 'mfa.phone_verification_code', identifier: SignInIdentifier.Phone }, + ])( + 'sends the MFA code for $identifier through the MFA hook with the flow state', + async ({ nameKey, identifier }) => { + renderList(mfaMethods); + + await clickMethod(nameKey); + + expect(mockedSendMfaVerificationCode).toHaveBeenCalledTimes(1); + expect(mockedSendMfaVerificationCode).toHaveBeenCalledWith(identifier, mfaFlowState); + // The pinned-user code API is not used for an enrolled MFA factor. + expect(mockedSendStepUpVerificationCode).not.toHaveBeenCalled(); + expect(mockedNavigate).not.toHaveBeenCalled(); + expect(mockedStartWebAuthnProcessing).not.toHaveBeenCalled(); + } + ); + + it('omits the identifiers the server did not provide from the flow state', async () => { + renderList([VerificationType.TOTP, VerificationType.MfaEmailVerificationCode], { phone }); + + await clickMethod('mfa.totp'); + + expect(mockedNavigate).toHaveBeenCalledWith( + `/${UserMfaFlow.MfaVerification}/${MfaFactor.TOTP}`, + { + replace: undefined, + state: { + availableFactors: [MfaFactor.TOTP, MfaFactor.EmailVerificationCode], + maskedIdentifiers: {}, + }, + } + ); + }); + }); + + describe('error handling of the MFA hooks', () => { + const acrNotSatisfied: RequestErrorBody = { + code: 'session.step_up.acr_not_satisfied', + message: 'The selected class is not satisfied.', + data: {}, + }; + + it('wires the step-up error handlers into the WebAuthn and MFA code hooks', async () => { + renderList(mfaMethods); + + const [webAuthnOptions] = mockedUseStartWebAuthnProcessing.mock.calls[0] ?? []; + const [mfaCodeOptions] = mockedUseSendMfaVerificationCode.mock.calls[0] ?? []; + + expect(new Set(Object.keys(webAuthnOptions?.errorHandlers ?? {}))).toEqual(stepUpErrorCodes); + // The list passes no `replace`, and both hooks share the one memoized handler set. + expect(mfaCodeOptions).toEqual({ errorHandlers: webAuthnOptions?.errorHandlers }); + expect(mfaCodeOptions?.errorHandlers).toBe(webAuthnOptions?.errorHandlers); + + await webAuthnOptions?.errorHandlers?.['session.step_up.acr_not_satisfied']?.( + acrNotSatisfied + ); + + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate).toHaveBeenCalledWith('/step-up', { replace: true }); + }); + }); +}); diff --git a/packages/experience/src/containers/StepUpMethodList/index.tsx b/packages/experience/src/containers/StepUpMethodList/index.tsx new file mode 100644 index 000000000000..ca67fb4c3c18 --- /dev/null +++ b/packages/experience/src/containers/StepUpMethodList/index.tsx @@ -0,0 +1,39 @@ +import { type InteractionAuthenticationContext } from '@logto/schemas'; + +import StepUpMethodButton from '@/components/Button/StepUpMethodButton'; +import { getMaskedIdentifier, type StepUpMethod } from '@/utils/step-up'; + +import styles from './index.module.scss'; +import useSelectStepUpMethod from './use-select-step-up-method'; + +type Props = { + /** The methods to offer, in the server's order. */ + readonly methods: readonly StepUpMethod[]; + readonly authenticationContext: Pick; +}; + +/** + * The step-up method chooser, modeled on `MfaFactorList`: one button per method Core offers, + * with the server-provided masked identifier as the subtitle of a code method. The list renders + * only what it is given; it never decides which methods are eligible. + */ +const StepUpMethodList = ({ methods, authenticationContext }: Props) => { + const selectMethod = useSelectStepUpMethod({ methods, authenticationContext }); + + return ( +
+ {methods.map((method) => ( + { + await selectMethod(method); + }} + /> + ))} +
+ ); +}; + +export default StepUpMethodList; diff --git a/packages/experience/src/containers/StepUpMethodList/use-select-step-up-method.ts b/packages/experience/src/containers/StepUpMethodList/use-select-step-up-method.ts new file mode 100644 index 000000000000..9ff3a025070d --- /dev/null +++ b/packages/experience/src/containers/StepUpMethodList/use-select-step-up-method.ts @@ -0,0 +1,134 @@ +import { + type InteractionAuthenticationContext, + MfaFactor, + SignInIdentifier, + VerificationType, +} from '@logto/schemas'; +import { useCallback, useContext, useMemo } from 'react'; + +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { sendStepUpVerificationCode } from '@/apis/experience'; +import { getStepUpVerificationCodeRoute, stepUpRoutes } from '@/constants/step-up'; +import useApi from '@/hooks/use-api'; +import useErrorHandler from '@/hooks/use-error-handler'; +import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params'; +import useSendMfaVerificationCode from '@/hooks/use-send-mfa-verification-code'; +import useStartWebAuthnProcessing from '@/hooks/use-start-webauthn-processing'; +import useStepUpErrorHandler from '@/hooks/use-step-up-error-handler'; +import { UserMfaFlow, type VerificationCodeIdentifier } from '@/types'; +import { codeVerificationTypeMap } from '@/utils/sign-in-experience'; +import { type StepUpMethod, toMfaFlowState } from '@/utils/step-up'; + +type Options = { + /** The displayed methods; the MFA pages receive them as their available factors. */ + methods: readonly StepUpMethod[]; + authenticationContext: Pick; + /** Whether to replace the current page in the history stack on navigation. */ + replace?: boolean; +}; + +/** + * Start the verification of a step-up method: + * + * - The pinned-user password and primary code go to the `/step-up` pages; a code is sent first + * through the pinned variant, so the raw identifier never leaves the server. + * - The enrolled MFA factors reuse the existing `/mfa-verification/:factor` pages exactly as the + * MFA error handler starts them. + */ +const useSelectStepUpMethod = ({ methods, authenticationContext, replace }: Options) => { + const navigate = useNavigateWithPreservedSearchParams(); + const handleError = useErrorHandler(); + const stepUpErrorHandlers = useStepUpErrorHandler(); + const asyncSendStepUpVerificationCode = useApi(sendStepUpVerificationCode); + const { setVerificationId } = useContext(UserInteractionContext); + const startWebAuthnProcessing = useStartWebAuthnProcessing({ + errorHandlers: stepUpErrorHandlers, + }); + const { onSubmit: sendMfaVerificationCode } = useSendMfaVerificationCode({ + replace, + errorHandlers: stepUpErrorHandlers, + }); + + const mfaFlowState = useMemo( + () => toMfaFlowState(methods, authenticationContext), + [authenticationContext, methods] + ); + + const sendPrimaryCode = useCallback( + async (type: VerificationCodeIdentifier) => { + const [error, result] = await asyncSendStepUpVerificationCode(type); + + if (error) { + await handleError(error, stepUpErrorHandlers); + return; + } + + if (result) { + setVerificationId(codeVerificationTypeMap[type], result.verificationId); + navigate(getStepUpVerificationCodeRoute(type), { replace }); + } + }, + [ + asyncSendStepUpVerificationCode, + handleError, + navigate, + replace, + setVerificationId, + stepUpErrorHandlers, + ] + ); + + return useCallback( + async (method: StepUpMethod) => { + switch (method) { + case VerificationType.Password: { + navigate(stepUpRoutes.password, { replace }); + return; + } + case VerificationType.EmailVerificationCode: { + await sendPrimaryCode(SignInIdentifier.Email); + return; + } + case VerificationType.PhoneVerificationCode: { + await sendPrimaryCode(SignInIdentifier.Phone); + return; + } + case VerificationType.WebAuthn: { + await startWebAuthnProcessing(UserMfaFlow.MfaVerification, mfaFlowState, replace); + return; + } + case VerificationType.MfaEmailVerificationCode: { + await sendMfaVerificationCode(SignInIdentifier.Email, mfaFlowState); + return; + } + case VerificationType.MfaPhoneVerificationCode: { + await sendMfaVerificationCode(SignInIdentifier.Phone, mfaFlowState); + return; + } + case VerificationType.TOTP: { + navigate(`/${UserMfaFlow.MfaVerification}/${MfaFactor.TOTP}`, { + replace, + state: mfaFlowState, + }); + return; + } + case VerificationType.BackupCode: { + navigate(`/${UserMfaFlow.MfaVerification}/${MfaFactor.BackupCode}`, { + replace, + state: mfaFlowState, + }); + } + } + }, + [ + mfaFlowState, + navigate, + replace, + sendMfaVerificationCode, + sendPrimaryCode, + startWebAuthnProcessing, + ] + ); +}; + +export default useSelectStepUpMethod; diff --git a/packages/experience/src/containers/StepUpSubjectProofList/index.module.scss b/packages/experience/src/containers/StepUpSubjectProofList/index.module.scss new file mode 100644 index 000000000000..e972d9f42449 --- /dev/null +++ b/packages/experience/src/containers/StepUpSubjectProofList/index.module.scss @@ -0,0 +1,6 @@ +@use '@/shared/scss/underscore' as _; + +.connectorList { + @include _.flex-column; + gap: _.unit(4); +} diff --git a/packages/experience/src/containers/StepUpSubjectProofList/index.test.tsx b/packages/experience/src/containers/StepUpSubjectProofList/index.test.tsx new file mode 100644 index 000000000000..fa33720c076f --- /dev/null +++ b/packages/experience/src/containers/StepUpSubjectProofList/index.test.tsx @@ -0,0 +1,182 @@ +import { type SsoConnectorMetadata, type SubjectProofConnector } from '@logto/schemas'; +import { act, fireEvent, screen, within } from '@testing-library/react'; + +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; +import { mockSignInExperienceSettings, socialConnectors } from '@/__mocks__/logto'; +import { type SignInExperienceResponse } from '@/types'; + +import StepUpSubjectProofList from '.'; + +const mockedInvokeSocialSignIn = jest.fn(); +const mockedInvokeSso = jest.fn(); + +jest.mock('@/containers/SocialSignInList/use-social', () => ({ + __esModule: true, + default: () => ({ + invokeSocialSignIn: mockedInvokeSocialSignIn, + theme: 'light', + socialConnectors: [], + }), +})); + +jest.mock('@/hooks/use-single-sign-on', () => ({ + __esModule: true, + default: () => mockedInvokeSso, +})); + +jest.mock('@/hooks/use-native-message-listener', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const ssoConnector: SsoConnectorMetadata = { + id: 'sso-1', + connectorName: 'Okta', + logo: 'https://logo/okta.png', + darkLogo: undefined, +}; + +// The GitHub connector from the mocked sign-in experience settings. +const githubConnector = socialConnectors[0]!; + +const settings: SignInExperienceResponse = { + ...mockSignInExperienceSettings, + ssoConnectors: [ssoConnector], +}; + +const socialProof: SubjectProofConnector = { type: 'social', connectorId: githubConnector.id }; +const ssoProof: SubjectProofConnector = { type: 'sso', connectorId: ssoConnector.id }; + +const renderList = (connectors: readonly SubjectProofConnector[]) => + renderWithPageContext( + + + + ); + +/** The connector logo the button renders: `alt` is the target (social) or connector name (sso). */ +const getButtonLogo = (button: HTMLElement) => { + const image = within(button).getByRole('img'); + + return { alt: image.getAttribute('alt'), src: image.getAttribute('src') }; +}; + +describe('StepUpSubjectProofList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders one button per resolvable connector in the given order (social then sso)', () => { + renderList([socialProof, ssoProof]); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + + const [socialButton, ssoButton] = buttons; + + expect(socialButton?.textContent).toBe('action.sign_in_with'); + expect(getButtonLogo(socialButton!)).toEqual({ + alt: githubConnector.target, + src: githubConnector.logo, + }); + + expect(ssoButton?.textContent).toBe('action.sign_in_with'); + expect(getButtonLogo(ssoButton!)).toEqual({ + alt: ssoConnector.connectorName, + src: ssoConnector.logo, + }); + }); + + it('keeps the server order when sso comes before social', () => { + renderList([ssoProof, socialProof]); + + const buttons = screen.getAllByRole('button'); + expect(buttons.map((button) => getButtonLogo(button).alt)).toEqual([ + ssoConnector.connectorName, + githubConnector.target, + ]); + }); + + it('renders every social connector the sign-in experience knows', () => { + const proofs = socialConnectors.map(({ id }) => ({ + type: 'social', + connectorId: id, + })); + + renderList(proofs); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(socialConnectors.length); + expect(buttons.map((button) => getButtonLogo(button).alt)).toEqual( + socialConnectors.map(({ target }) => target) + ); + }); + + it.each<{ readonly unknown: SubjectProofConnector; readonly expectedAlt: string }>([ + { + unknown: { type: 'social', connectorId: 'unknown-social-connector' }, + expectedAlt: ssoConnector.connectorName, + }, + { + unknown: { type: 'sso', connectorId: 'unknown-sso-connector' }, + expectedAlt: githubConnector.target, + }, + ])('skips an unknown $unknown.type connector id', ({ unknown, expectedAlt }) => { + const known = unknown.type === 'social' ? ssoProof : socialProof; + + renderList([unknown, known]); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(1); + expect(getButtonLogo(buttons[0]!).alt).toBe(expectedAlt); + }); + + it('renders no button when none of the connector ids is known', () => { + const { container } = renderList([ + { type: 'social', connectorId: 'unknown-social-connector' }, + { type: 'sso', connectorId: 'unknown-sso-connector' }, + ]); + + expect(screen.queryByRole('button')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + }); + + it('invokes social sign-in with the full connector object from the settings', async () => { + renderList([socialProof, ssoProof]); + + const [socialButton] = screen.getAllByRole('button'); + + await act(async () => { + fireEvent.click(socialButton!); + }); + + expect(mockedInvokeSocialSignIn).toHaveBeenCalledTimes(1); + expect(mockedInvokeSocialSignIn).toHaveBeenCalledWith(githubConnector); + // The very object from the settings is forwarded, not a re-shaped copy. + expect(mockedInvokeSocialSignIn.mock.calls[0]?.[0]).toBe(githubConnector); + expect(mockedInvokeSso).not.toHaveBeenCalled(); + }); + + it('invokes single sign-on with the sso connector id', async () => { + renderList([socialProof, ssoProof]); + + const [, ssoButton] = screen.getAllByRole('button'); + + await act(async () => { + fireEvent.click(ssoButton!); + }); + + expect(mockedInvokeSso).toHaveBeenCalledTimes(1); + expect(mockedInvokeSso).toHaveBeenCalledWith(ssoConnector.id); + expect(mockedInvokeSocialSignIn).not.toHaveBeenCalled(); + }); + + it('renders an empty list for an empty connectors prop', () => { + const { container } = renderList([]); + + expect(screen.queryByRole('button')).toBeNull(); + expect(container.querySelector('.connectorList')).not.toBeNull(); + expect(container.querySelector('.connectorList')?.childElementCount).toBe(0); + }); +}); diff --git a/packages/experience/src/containers/StepUpSubjectProofList/index.tsx b/packages/experience/src/containers/StepUpSubjectProofList/index.tsx new file mode 100644 index 000000000000..bb20c845853a --- /dev/null +++ b/packages/experience/src/containers/StepUpSubjectProofList/index.tsx @@ -0,0 +1,79 @@ +import { type SubjectProofConnector } from '@logto/schemas'; +import { useMemo, useState } from 'react'; + +import SocialLinkButton from '@/components/Button/SocialLinkButton'; +import useSocial from '@/containers/SocialSignInList/use-social'; +import useConnectors from '@/hooks/use-connectors'; +import useNativeMessageListener from '@/hooks/use-native-message-listener'; +import { useSieMethods } from '@/hooks/use-sie'; +import useSingleSignOn from '@/hooks/use-single-sign-on'; + +import styles from './index.module.scss'; + +type Props = { + /** The linked connectors Core offers as subject proof, in the server's order. */ + readonly connectors: readonly SubjectProofConnector[]; +}; + +/** + * The social / enterprise SSO connectors a user with no verifiable method can re-run as subject + * proof before establishing a method. Only the connectors Core lists are rendered, resolved by + * the type Core states against the sign-in experience for their name and logo; an id the + * experience does not enable is skipped. The buttons reuse the existing social / SSO redirects + * and callback pages. + */ +const StepUpSubjectProofList = ({ connectors }: Props) => { + const { socialConnectors, ssoConnectors } = useSieMethods(); + const { getConnectorLogo } = useConnectors(); + const { invokeSocialSignIn } = useSocial(); + const invokeSingleSignOn = useSingleSignOn(); + const [loadingConnectorId, setLoadingConnectorId] = useState(); + useNativeMessageListener(); + + const resolvedConnectors = useMemo( + () => + connectors + .map(({ type, connectorId }) => { + if (type === 'sso') { + const connector = ssoConnectors.find(({ id }) => id === connectorId); + + return connector && { type, connector }; + } + + const connector = socialConnectors.find(({ id }) => id === connectorId); + + return connector && { type, connector }; + }) + .filter( + (resolved): resolved is Exclude => resolved !== undefined + ), + [connectors, socialConnectors, ssoConnectors] + ); + + return ( +
+ {resolvedConnectors.map((resolved) => { + const { connector, type } = resolved; + const { id } = connector; + + return ( + { + setLoadingConnectorId(id); + await (type === 'social' ? invokeSocialSignIn(connector) : invokeSingleSignOn(id)); + setLoadingConnectorId(undefined); + }} + /> + ); + })} +
+ ); +}; + +export default StepUpSubjectProofList; diff --git a/packages/experience/src/hooks/use-send-mfa-verification-code.ts b/packages/experience/src/hooks/use-send-mfa-verification-code.ts index 5a26a8b88ac9..91080a202377 100644 --- a/packages/experience/src/hooks/use-send-mfa-verification-code.ts +++ b/packages/experience/src/hooks/use-send-mfa-verification-code.ts @@ -4,7 +4,7 @@ import { useCallback, useContext, useState } from 'react'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import { sendMfaVerificationCode } from '@/apis/experience'; import useApi from '@/hooks/use-api'; -import useErrorHandler from '@/hooks/use-error-handler'; +import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler'; import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params'; import { type VerificationCodeIdentifier } from '@/types'; import { type MfaFlowState } from '@/types/guard'; @@ -13,9 +13,11 @@ import { codeVerificationTypeMap } from '@/utils/sign-in-experience'; type Options = { /** Whether to replace the current page in the history stack on navigation. */ replace?: boolean; + /** Handlers for the errors of sending the code, on top of the default toast. */ + errorHandlers?: ErrorHandlers; }; -const useSendMfaVerificationCode = ({ replace }: Options = {}) => { +const useSendMfaVerificationCode = ({ replace, errorHandlers }: Options = {}) => { const [errorMessage, setErrorMessage] = useState(); const navigate = useNavigateWithPreservedSearchParams(); @@ -32,7 +34,7 @@ const useSendMfaVerificationCode = ({ replace }: Options = {}) => { const [error, result] = await asyncSendVerificationCode(identifier); if (error) { - await handleError(error); + await handleError(error, errorHandlers); return; } @@ -47,7 +49,7 @@ const useSendMfaVerificationCode = ({ replace }: Options = {}) => { ); } }, - [asyncSendVerificationCode, handleError, navigate, replace, setVerificationId] + [asyncSendVerificationCode, errorHandlers, handleError, navigate, replace, setVerificationId] ); return { diff --git a/packages/experience/src/hooks/use-start-webauthn-processing.ts b/packages/experience/src/hooks/use-start-webauthn-processing.ts index 31be2560c7dd..adf5648133a3 100644 --- a/packages/experience/src/hooks/use-start-webauthn-processing.ts +++ b/packages/experience/src/hooks/use-start-webauthn-processing.ts @@ -8,9 +8,14 @@ import { UserMfaFlow } from '@/types'; import { type WebAuthnState, type MfaFlowState } from '@/types/guard'; import useApi from './use-api'; -import useErrorHandler from './use-error-handler'; +import useErrorHandler, { type ErrorHandlers } from './use-error-handler'; -const useStartWebAuthnProcessing = () => { +type Options = { + /** Handlers for the errors of creating the WebAuthn options, on top of the default toast. */ + errorHandlers?: ErrorHandlers; +}; + +const useStartWebAuthnProcessing = ({ errorHandlers }: Options = {}) => { const navigate = useNavigateWithPreservedSearchParams(); const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistration); const asyncGenerateAuthnOptions = useApi(createWebAuthnAuthentication); @@ -25,7 +30,7 @@ const useStartWebAuthnProcessing = () => { : await asyncGenerateAuthnOptions(); if (error) { - await handleError(error); + await handleError(error, errorHandlers); return; } @@ -44,6 +49,7 @@ const useStartWebAuthnProcessing = () => { [ asyncCreateRegistrationOptions, asyncGenerateAuthnOptions, + errorHandlers, handleError, navigate, setVerificationId, diff --git a/packages/experience/src/hooks/use-step-up-context.ts b/packages/experience/src/hooks/use-step-up-context.ts new file mode 100644 index 000000000000..ce521093c210 --- /dev/null +++ b/packages/experience/src/hooks/use-step-up-context.ts @@ -0,0 +1,8 @@ +import { useContext } from 'react'; + +import StepUpContext from '@/Providers/StepUpContextProvider/StepUpContext'; + +/** The server-driven step-up state loaded by the `/step-up` route guard. */ +const useStepUpContext = () => useContext(StepUpContext); + +export default useStepUpContext; diff --git a/packages/experience/src/hooks/use-step-up-error-handler.test.tsx b/packages/experience/src/hooks/use-step-up-error-handler.test.tsx new file mode 100644 index 000000000000..20c553fd4a6e --- /dev/null +++ b/packages/experience/src/hooks/use-step-up-error-handler.test.tsx @@ -0,0 +1,95 @@ +import { type LogtoErrorCode } from '@logto/phrases'; +import { type RequestErrorBody } from '@logto/schemas'; +import { act, renderHook } from '@testing-library/react'; + +import useStepUpErrorHandler from './use-step-up-error-handler'; + +const mockedNavigate = jest.fn(); + +jest.mock('./use-navigate-with-preserved-search-params', () => ({ + __esModule: true, + default: () => mockedNavigate, +})); + +const buildError = (code: LogtoErrorCode): RequestErrorBody => ({ + code, + message: `Server message for ${code}`, + data: {}, +}); + +describe('useStepUpErrorHandler', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + 'session.not_found', + 'session.interaction_not_found', + 'session.step_up.subject_not_found', + 'session.step_up.invalid_interaction_event', + 'session.identity_conflict', + 'session.step_up.forbidden_route', + ])('replaces the current entry with the invalid-session page on %s', async (code) => { + const { result } = renderHook(() => useStepUpErrorHandler()); + const handler = result.current[code]; + + expect(handler).toBeDefined(); + + await act(async () => { + await handler?.(buildError(code)); + }); + + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate).toHaveBeenCalledWith('/unknown-session', { replace: true }); + }); + + it('sends the user back to the step-up landing when the selected class is not satisfied', async () => { + const { result } = renderHook(() => useStepUpErrorHandler()); + const handler = result.current['session.step_up.acr_not_satisfied']; + + expect(handler).toBeDefined(); + + await act(async () => { + await handler?.(buildError('session.step_up.acr_not_satisfied')); + }); + + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate).toHaveBeenCalledWith('/step-up', { replace: true }); + }); + + it.each([ + 'session.verification_blocked_too_many_attempts', + 'session.mfa.require_mfa_verification', + ])('leaves %s to its existing handling', (code) => { + const { result } = renderHook(() => useStepUpErrorHandler()); + + expect(result.current).not.toHaveProperty(code); + expect(result.current[code]).toBeUndefined(); + }); + + it('only registers the step-up specific codes and no global fallback', () => { + const { result } = renderHook(() => useStepUpErrorHandler()); + + expect(new Set(Object.keys(result.current))).toEqual( + new Set([ + 'session.identity_conflict', + 'session.interaction_not_found', + 'session.not_found', + 'session.step_up.acr_not_satisfied', + 'session.step_up.forbidden_route', + 'session.step_up.invalid_interaction_event', + 'session.step_up.subject_not_found', + ]) + ); + expect(result.current.global).toBeUndefined(); + }); + + it('returns the same handlers object across rerenders', () => { + const { result, rerender } = renderHook(() => useStepUpErrorHandler()); + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); +}); diff --git a/packages/experience/src/hooks/use-step-up-error-handler.ts b/packages/experience/src/hooks/use-step-up-error-handler.ts new file mode 100644 index 000000000000..b5eeaf73306c --- /dev/null +++ b/packages/experience/src/hooks/use-step-up-error-handler.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +import { + stepUpRoutes, + stepUpSessionGoneErrorCodes, + unknownSessionRoute, +} from '@/constants/step-up'; + +import type { ErrorHandlers } from './use-error-handler'; +import useNavigateWithPreservedSearchParams from './use-navigate-with-preserved-search-params'; + +/** + * The errors that land on the invalid-session page: a session that is gone, and a session that + * the request cannot belong to (the verified identity is not the pinned subject, or the route is + * not allowed in step-up). + */ +const invalidSessionErrorCodes = Object.freeze([ + ...stepUpSessionGoneErrorCodes, + 'session.identity_conflict', + 'session.step_up.forbidden_route', +] as const); + +/** + * The error handlers shared by the step-up screens. Compose them into the handlers of every call + * a step-up screen makes; the sentinel lockout errors and the MFA / profile errors keep their + * existing handling. + * + * - The interaction is gone, the verified identity is not the pinned subject, or the route is not + * allowed in step-up: the invalid-session page. Nothing on the screen can recover from these. + * - The completed verification does not satisfy the selected class: back to the method list, + * which refetches the authoritative context, so the user can add the missing verification. + */ +const useStepUpErrorHandler = (): ErrorHandlers => { + const navigate = useNavigateWithPreservedSearchParams(); + + return useMemo(() => { + const showInvalidSession = () => { + navigate(unknownSessionRoute, { replace: true }); + }; + + return { + ...Object.fromEntries(invalidSessionErrorCodes.map((code) => [code, showInvalidSession])), + 'session.step_up.acr_not_satisfied': () => { + navigate(stepUpRoutes.landing, { replace: true }); + }, + }; + }, [navigate]); +}; + +export default useStepUpErrorHandler; diff --git a/packages/experience/src/hooks/use-submit-interaction-error-handler.test.tsx b/packages/experience/src/hooks/use-submit-interaction-error-handler.test.tsx new file mode 100644 index 000000000000..76dadbc710c2 --- /dev/null +++ b/packages/experience/src/hooks/use-submit-interaction-error-handler.test.tsx @@ -0,0 +1,182 @@ +import { InteractionEvent, type RequestErrorBody } from '@logto/schemas'; +import { act, renderHook } from '@testing-library/react'; + +import { type ErrorHandlers } from './use-error-handler'; +import useSubmitInteractionErrorHandler from './use-submit-interaction-error-handler'; + +const mockedNavigate = jest.fn(); + +const mockRequiredProfileHandler = jest.fn(); +const mockMfaHandler = jest.fn(); +const mockEmailBlockedHandler = jest.fn(); +const mockMissingPasskeyHandler = jest.fn(); +const mockTrustedDeviceOptInHandler = jest.fn(); + +/** + * Stable marker objects so that the memoized result of the hook under test only changes when one + * of the composed handlers actually changes. + */ +const mockRequiredProfileHandlers: ErrorHandlers = { + 'user.missing_profile': mockRequiredProfileHandler, +}; +const mockMfaHandlers: ErrorHandlers = { + 'user.missing_mfa': mockMfaHandler, +}; +const mockEmailBlockedHandlers: ErrorHandlers = { + 'session.email_blocklist.email_not_allowed': mockEmailBlockedHandler, +}; +const mockMissingPasskeyHandlers: ErrorHandlers = { + 'user.passkey_preferred': mockMissingPasskeyHandler, +}; +const mockTrustedDeviceOptInHandlers: ErrorHandlers = { + 'session.trusted_device_suggest_opt_in': mockTrustedDeviceOptInHandler, +}; + +const mockUseRequiredProfileErrorHandler = jest.fn( + () => mockRequiredProfileHandlers +); +const mockUseMfaErrorHandler = jest.fn(() => mockMfaHandlers); +const mockUseEmailBlockedErrorHandler = jest.fn( + () => mockEmailBlockedHandlers +); +const mockUseMissingPasskeyErrorHandler = jest.fn( + () => mockMissingPasskeyHandlers +); +const mockUseTrustedDeviceOptInErrorHandler = jest.fn( + () => mockTrustedDeviceOptInHandlers +); + +jest.mock('./use-navigate-with-preserved-search-params', () => ({ + __esModule: true, + default: () => mockedNavigate, +})); + +jest.mock('./use-required-profile-error-handler', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseRequiredProfileErrorHandler(...args), +})); + +jest.mock('./use-mfa-error-handler', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseMfaErrorHandler(...args), +})); + +jest.mock('./use-email-blocked-error-handler', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseEmailBlockedErrorHandler(...args), +})); + +jest.mock('./use-missing-passkey-error-handler', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseMissingPasskeyErrorHandler(...args), +})); + +jest.mock('./use-trusted-device-opt-in-error-handler', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseTrustedDeviceOptInErrorHandler(...args), +})); + +describe('useSubmitInteractionErrorHandler', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to the step-up landing with nothing in the location state on require_verification', async () => { + const { result } = renderHook(() => useSubmitInteractionErrorHandler(InteractionEvent.SignIn)); + const handler = result.current['session.step_up.require_verification']; + const error: RequestErrorBody = { + code: 'session.step_up.require_verification', + message: 'Verification is required.', + data: { availableMethods: ['Password'], futureField: 'ignored' }, + }; + + expect(handler).toBeDefined(); + + await act(async () => { + await handler?.(error); + }); + + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate).toHaveBeenCalledWith('/step-up', { replace: true }); + // The navigation must be exactly `(landing, { replace: true })`: nothing from the error is + // carried in `location.state`, so the continuation is refresh-safe. + expect(mockedNavigate.mock.calls[0]).toHaveLength(2); + expect(mockedNavigate.mock.calls[0]?.[1]).toStrictEqual({ replace: true }); + }); + + it('keeps the composed handlers of the sibling hooks', () => { + const { result } = renderHook(() => useSubmitInteractionErrorHandler(InteractionEvent.SignIn)); + + expect(result.current['user.missing_profile']).toBe(mockRequiredProfileHandler); + expect(result.current['user.missing_mfa']).toBe(mockMfaHandler); + expect(result.current['session.email_blocklist.email_not_allowed']).toBe( + mockEmailBlockedHandler + ); + expect(result.current['user.passkey_preferred']).toBe(mockMissingPasskeyHandler); + expect(result.current['session.trusted_device_suggest_opt_in']).toBe( + mockTrustedDeviceOptInHandler + ); + expect(typeof result.current['session.step_up.require_verification']).toBe('function'); + expect(new Set(Object.keys(result.current))).toEqual( + new Set([ + 'session.email_blocklist.email_not_allowed', + 'session.step_up.require_verification', + 'session.trusted_device_suggest_opt_in', + 'user.missing_mfa', + 'user.missing_profile', + 'user.passkey_preferred', + ]) + ); + }); + + it('forwards the interaction event and options to the sibling hooks', () => { + const onEmailBlocked = jest.fn(); + + renderHook(() => + useSubmitInteractionErrorHandler(InteractionEvent.Register, { + replace: true, + linkSocial: 'github', + onEmailBlocked, + }) + ); + + expect(mockUseRequiredProfileErrorHandler).toHaveBeenCalledWith({ + replace: true, + linkSocial: 'github', + interactionEvent: InteractionEvent.Register, + }); + expect(mockUseMfaErrorHandler).toHaveBeenCalledWith({ replace: true }); + expect(mockUseEmailBlockedErrorHandler).toHaveBeenCalledWith({ onConfirm: onEmailBlocked }); + expect(mockUseMissingPasskeyErrorHandler).toHaveBeenCalledWith(InteractionEvent.Register); + expect(mockUseTrustedDeviceOptInErrorHandler).toHaveBeenCalledWith(InteractionEvent.Register); + }); + + it('returns a referentially stable handlers object when the inputs do not change', () => { + const { result, rerender } = renderHook(() => + useSubmitInteractionErrorHandler(InteractionEvent.SignIn) + ); + const firstResult = result.current; + + rerender(); + rerender(); + + expect(result.current).toBe(firstResult); + }); + + it('recomputes the handlers when a composed handler changes', () => { + const { result, rerender } = renderHook(() => + useSubmitInteractionErrorHandler(InteractionEvent.SignIn) + ); + const firstResult = result.current; + const replacementMfaHandler = jest.fn(); + const replacementMfaHandlers: ErrorHandlers = { 'user.missing_mfa': replacementMfaHandler }; + + mockUseMfaErrorHandler.mockReturnValueOnce(replacementMfaHandlers); + rerender(); + + expect(result.current).not.toBe(firstResult); + expect(result.current['user.missing_mfa']).toBe(replacementMfaHandler); + // Untouched siblings are still spread into the recomputed object. + expect(result.current['user.missing_profile']).toBe(mockRequiredProfileHandler); + }); +}); diff --git a/packages/experience/src/hooks/use-submit-interaction-error-handler.ts b/packages/experience/src/hooks/use-submit-interaction-error-handler.ts index 3343cb63024d..898a3a439807 100644 --- a/packages/experience/src/hooks/use-submit-interaction-error-handler.ts +++ b/packages/experience/src/hooks/use-submit-interaction-error-handler.ts @@ -1,6 +1,7 @@ import { cond } from '@silverhand/essentials'; import { useMemo } from 'react'; +import { stepUpRoutes } from '@/constants/step-up'; import { type ContinueFlowInteractionEvent } from '@/types'; import useEmailBlockedErrorHandler from './use-email-blocked-error-handler'; @@ -9,6 +10,7 @@ import useMfaErrorHandler, { type Options as UseMfaVerificationErrorHandlerOptions, } from './use-mfa-error-handler'; import useMissingPasskeyErrorHandler from './use-missing-passkey-error-handler'; +import useNavigateWithPreservedSearchParams from './use-navigate-with-preserved-search-params'; import useRequiredProfileErrorHandler, { type Options as UseRequiredProfileErrorHandlerOptions, } from './use-required-profile-error-handler'; @@ -36,6 +38,7 @@ const useSubmitInteractionErrorHandler = ( interactionEvent: ContinueFlowInteractionEvent, { replace, onEmailBlocked, ...rest }: Options = {} ): ErrorHandlers => { + const navigate = useNavigateWithPreservedSearchParams(); const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, interactionEvent, @@ -53,10 +56,20 @@ const useSubmitInteractionErrorHandler = ( ...mfaErrorHandler, ...cond(passkeySignInErrorHandler), ...trustedDeviceOptInErrorHandler, + /** + * A sign-in with requested ACR needs a first factor the user can verify. The error is only a + * navigation signal: the step-up landing reads `availableMethods` and the masked identifiers + * from `GET /experience/interaction` and forwards to the pinned-user first-factor page, so + * the continuation is refresh-safe and carries nothing in `location.state`. + */ + 'session.step_up.require_verification': () => { + navigate(stepUpRoutes.landing, { replace: true }); + }, }), [ emailBlockedErrorHandler, mfaErrorHandler, + navigate, passkeySignInErrorHandler, requiredProfileErrorHandler, trustedDeviceOptInErrorHandler, diff --git a/packages/experience/src/pages/StepUp/Password/PasswordForm.tsx b/packages/experience/src/pages/StepUp/Password/PasswordForm.tsx new file mode 100644 index 000000000000..eba01cdc915b --- /dev/null +++ b/packages/experience/src/pages/StepUp/Password/PasswordForm.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { PasswordInputField } from '@/components/InputFields'; +import Button from '@/shared/components/Button'; +import ErrorMessage from '@/shared/components/ErrorMessage'; + +import styles from './index.module.scss'; +import useStepUpPasswordVerification from './use-step-up-password-verification'; + +type FormState = { + password: string; +}; + +/** + * The pinned-user password form: the password field of the sign-in password form and nothing + * else. The subject is pinned server-side, so there is no identifier to show or switch, and the + * forgot-password link is absent because the mode rejects the event switch it would start. + */ +const PasswordForm = () => { + const { t } = useTranslation(); + const { errorMessage, clearErrorMessage, onSubmit } = useStepUpPasswordVerification(); + + const { + register, + handleSubmit, + formState: { errors, isValid, isSubmitting }, + } = useForm({ + reValidateMode: 'onBlur', + defaultValues: { password: '' }, + }); + + useEffect(() => { + if (!isValid) { + clearErrorMessage(); + } + }, [clearErrorMessage, isValid]); + + const onSubmitHandler = useCallback( + async (event?: React.FormEvent) => { + clearErrorMessage(); + + await handleSubmit(async ({ password }) => { + await onSubmit(password); + })(event); + }, + [clearErrorMessage, handleSubmit, onSubmit] + ); + + return ( +
+ + + {errorMessage && {errorMessage}} + + + + + + + ); +}; + +/** A context whose content stays the same while a refetch hands out a new object, like the real provider. */ +const Refetchable = ({ + authenticationContext, +}: { + readonly authenticationContext: InteractionAuthenticationContext; +}) => { + const [context, setContext] = useState(authenticationContext); + const value = useMemo( + () => ({ authenticationContext: context, isLoading: false, refetch }), + [context] + ); + + return ( + <> + + + + + + ); +}; + +const getListedItems = (testId: string) => + within(screen.getByTestId(testId)) + .getAllByRole('listitem') + .map(({ textContent }) => textContent); + +/** The forward never settles: in the real app the navigation unmounts the landing page. */ +const neverSettles = async () => + new Promise(() => { + // Intentionally pending. + }); + +/** The forward settles without navigating, e.g. a code request that failed and was toasted. */ +const settlesWithoutNavigating = async () => { + // Nothing to do. +}; + +describe('StepUp', () => { + beforeEach(() => { + // Every test starts from a forward that settles, so test order cannot change behavior. + mockedSelectMethod.mockImplementation(settlesWithoutNavigating); + }); + + afterEach(() => { + jest.clearAllMocks(); + // eslint-disable-next-line @silverhand/fp/no-mutation + window.logtoNativeSdk = undefined; + }); + + it('renders nothing and does not dispatch while the context is loading', () => { + const { container } = renderStepUp({ + authenticationContext: createAuthenticationContext({ + availableMethods: [VerificationType.Password], + }), + isLoading: true, + }); + + expect(container.innerHTML).toBe(''); + expect(mockedSelectMethod).not.toHaveBeenCalled(); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); + + it('dispatches only once the load settles, so a stale context never decides', () => { + mockedSelectMethod.mockImplementation(neverSettles); + + renderWithPageContext( + + ); + + expect(mockedSelectMethod).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('settle')); + + expect(mockedSelectMethod).toHaveBeenCalledTimes(1); + expect(mockedSelectMethod).toHaveBeenCalledWith(VerificationType.Password); + }); + + it('auto-forwards to the only method with a replacing navigation and renders nothing', () => { + mockedSelectMethod.mockImplementation(neverSettles); + const authenticationContext = createAuthenticationContext({ + availableMethods: [VerificationType.Password], + }); + + const { container } = renderStepUp({ authenticationContext }); + + expect(mockedSelectMethod).toHaveBeenCalledTimes(1); + expect(mockedSelectMethod).toHaveBeenCalledWith(VerificationType.Password); + expect(mockedUseSelectStepUpMethod).toHaveBeenLastCalledWith({ + methods: [VerificationType.Password], + authenticationContext, + replace: true, + }); + // The page itself never navigates; the selection hook owns the target. + expect(mockedNavigate).not.toHaveBeenCalled(); + expect(container.innerHTML).toBe(''); + expect(screen.queryByText('step_up.verify_your_identity')).toBeNull(); + expect(screen.queryByTestId('step-up-method-list')).toBeNull(); + }); + + it('falls back to the chooser with the single method when the forward settles without navigating', async () => { + mockedSelectMethod.mockImplementation(settlesWithoutNavigating); + + renderWithPageContext( + + ); + + await waitFor(() => { + expect(screen.getByText('step_up.verify_your_identity')).not.toBeNull(); + }); + + expect(screen.getByText('step_up.choose_method_description')).not.toBeNull(); + expect(getListedItems('step-up-method-list')).toEqual([VerificationType.EmailVerificationCode]); + expect(mockedSelectMethod).toHaveBeenCalledTimes(1); + expect(mockedSelectMethod).toHaveBeenCalledWith(VerificationType.EmailVerificationCode); + expect(mockedNavigate).not.toHaveBeenCalled(); + + // A refetch hands the page a new context object of the same content; the forward was + // attempted exactly once and the fallback never retries on its own. + fireEvent.click(screen.getByText('refetch')); + + expect(getListedItems('step-up-method-list')).toEqual([VerificationType.EmailVerificationCode]); + expect(mockedSelectMethod).toHaveBeenCalledTimes(1); + }); + + it('renders the chooser without auto-forwarding when several methods are available', () => { + renderStepUp({ + authenticationContext: createAuthenticationContext({ + availableMethods: [VerificationType.Password, VerificationType.TOTP], + }), + }); + + expect(screen.getByText('step_up.verify_your_identity')).not.toBeNull(); + expect(screen.getByText('step_up.choose_method_description')).not.toBeNull(); + expect(getListedItems('step-up-method-list')).toEqual([ + VerificationType.Password, + VerificationType.TOTP, + ]); + expect(screen.queryByText('step_up.subject_proof_description')).toBeNull(); + expect(screen.queryByTestId('step-up-subject-proof-list')).toBeNull(); + expect(mockedSelectMethod).not.toHaveBeenCalled(); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); + + it('ignores verification types the step-up screens cannot render', () => { + mockedSelectMethod.mockImplementation(neverSettles); + const authenticationContext = createAuthenticationContext({ + availableMethods: [VerificationType.Social, VerificationType.Password], + }); + + renderStepUp({ authenticationContext }); + + // Only the password is a step-up method, so it is the "only method" and is forwarded to. + expect(mockedUseSelectStepUpMethod).toHaveBeenLastCalledWith({ + methods: [VerificationType.Password], + authenticationContext, + replace: true, + }); + expect(mockedSelectMethod).toHaveBeenCalledTimes(1); + expect(mockedSelectMethod).toHaveBeenCalledWith(VerificationType.Password); + }); + + it('renders the subject-proof connectors when no method is available', () => { + renderStepUp({ + authenticationContext: createAuthenticationContext({ + subjectProofConnectors: [{ type: 'social', connectorId: 'c1' }], + }), + }); + + expect(screen.getByText('step_up.verify_your_identity')).not.toBeNull(); + expect(screen.getByText('step_up.subject_proof_description')).not.toBeNull(); + expect(getListedItems('step-up-subject-proof-list')).toEqual(['social:c1']); + expect(screen.queryByText('step_up.choose_method_description')).toBeNull(); + expect(screen.queryByTestId('step-up-method-list')).toBeNull(); + expect(mockedSelectMethod).not.toHaveBeenCalled(); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); + + it('prefers the subject-proof connectors over an establishable method', () => { + renderStepUp({ + authenticationContext: createAuthenticationContext({ + establishableMethods: [MissingProfile.password], + subjectProofConnectors: [ + { type: 'social', connectorId: 'c1' }, + { type: 'sso', connectorId: 'sso-1' }, + ], + }), + }); + + expect(getListedItems('step-up-subject-proof-list')).toEqual(['social:c1', 'sso:sso-1']); + expect(mockedNavigate).not.toHaveBeenCalled(); + expect(mockedSelectMethod).not.toHaveBeenCalled(); + }); + + it('forwards to the continue page of the first establishable method when nothing can be verified', () => { + const { container } = renderStepUp({ + authenticationContext: createAuthenticationContext({ + establishableMethods: [MissingProfile.password, MissingProfile.email], + }), + }); + + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate).toHaveBeenCalledWith('/continue/password', { + replace: true, + state: { interactionEvent: InteractionEvent.SignIn }, + }); + expect(container.innerHTML).toBe(''); + expect(mockedSelectMethod).not.toHaveBeenCalled(); + }); + + it('renders the no-method error page when nothing is available', () => { + renderStepUp({ authenticationContext: createAuthenticationContext() }); + + expect(screen.getByText('step_up.no_method_available')).not.toBeNull(); + expect(screen.getByText('step_up.no_method_available_description')).not.toBeNull(); + expect(screen.queryByText('step_up.verify_your_identity')).toBeNull(); + expect(mockedSelectMethod).not.toHaveBeenCalled(); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); + + it('drops WebAuthn on a native webview and auto-forwards to the remaining method', () => { + // eslint-disable-next-line @silverhand/fp/no-mutation + window.logtoNativeSdk = { + platform: 'android', + callbackLink: 'logto://callback', + getPostMessage: () => jest.fn(), + supportedConnector: { universal: false, nativeTargets: [] }, + }; + mockedSelectMethod.mockImplementation(neverSettles); + const authenticationContext = createAuthenticationContext({ + availableMethods: [VerificationType.WebAuthn, VerificationType.TOTP], + }); + + const { container } = renderStepUp({ authenticationContext }); + + expect(mockedUseSelectStepUpMethod).toHaveBeenLastCalledWith({ + methods: [VerificationType.TOTP], + authenticationContext, + replace: true, + }); + expect(mockedSelectMethod).toHaveBeenCalledTimes(1); + expect(mockedSelectMethod).toHaveBeenCalledWith(VerificationType.TOTP); + expect(container.innerHTML).toBe(''); + }); + + it('keeps WebAuthn on a native webview when it is the only method', () => { + // eslint-disable-next-line @silverhand/fp/no-mutation + window.logtoNativeSdk = { + platform: 'ios', + callbackLink: 'logto://callback', + getPostMessage: () => jest.fn(), + supportedConnector: { universal: false, nativeTargets: [] }, + }; + mockedSelectMethod.mockImplementation(neverSettles); + + renderStepUp({ + authenticationContext: createAuthenticationContext({ + availableMethods: [VerificationType.WebAuthn], + }), + }); + + expect(mockedSelectMethod).toHaveBeenCalledTimes(1); + expect(mockedSelectMethod).toHaveBeenCalledWith(VerificationType.WebAuthn); + }); +}); diff --git a/packages/experience/src/pages/StepUp/index.tsx b/packages/experience/src/pages/StepUp/index.tsx new file mode 100644 index 000000000000..95bdd8f993e1 --- /dev/null +++ b/packages/experience/src/pages/StepUp/index.tsx @@ -0,0 +1,125 @@ +import { InteractionEvent } from '@logto/schemas'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import StepUpMethodList from '@/containers/StepUpMethodList'; +import useSelectStepUpMethod from '@/containers/StepUpMethodList/use-select-step-up-method'; +import StepUpSubjectProofList from '@/containers/StepUpSubjectProofList'; +import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params'; +import useStepUpContext from '@/hooks/use-step-up-context'; +import ErrorPage from '@/pages/ErrorPage'; +import { UserFlow } from '@/types'; +import { getDisplayedStepUpMethods } from '@/utils/step-up'; + +const emptyMethods: never[] = []; +/** A stable placeholder while the context loads, so the selection hook keeps its identity. */ +const emptyAuthenticationContext = Object.freeze({ maskedIdentifiers: {} }); + +/** + * The `/step-up` landing: dispatches on the authentication context the guard loaded from Core. + * + * - Exactly one method: forward to it, replacing this entry so "back" does not return here. + * - Several methods: render the chooser. + * - No method: render the subject-proof connectors when Core lists any, or forward to the + * `/continue/:type` page when a first factor can be established. Core fast-fails at creation + * when nothing is possible, so the final fallback only covers a method removed mid-flow. + * + * Every decision reads the server-provided lists; nothing here computes eligibility. + */ +const StepUp = () => { + const { authenticationContext, isLoading } = useStepUpContext(); + const navigate = useNavigateWithPreservedSearchParams(); + const hasForwardedRef = useRef(false); + const [isForwarding, setIsForwarding] = useState(true); + + const methods = useMemo( + () => + authenticationContext + ? getDisplayedStepUpMethods(authenticationContext.availableMethods) + : emptyMethods, + [authenticationContext] + ); + const selectMethod = useSelectStepUpMethod({ + methods, + authenticationContext: authenticationContext ?? emptyAuthenticationContext, + replace: true, + }); + + useEffect(() => { + if (isLoading || !authenticationContext || hasForwardedRef.current) { + return; + } + + const [onlyMethod] = methods; + const { establishableMethods, subjectProofConnectors } = authenticationContext; + const [establishableMethod] = establishableMethods; + + if (methods.length === 1 && onlyMethod) { + // eslint-disable-next-line @silverhand/fp/no-mutation + hasForwardedRef.current = true; + // A method that starts with a request (sending a code, WebAuthn options) can fail; the + // chooser then renders the single method so the user can retry. + const forward = async () => { + try { + await selectMethod(onlyMethod); + } finally { + setIsForwarding(false); + } + }; + + void forward(); + return; + } + + if (methods.length === 0 && subjectProofConnectors.length === 0 && establishableMethod) { + // eslint-disable-next-line @silverhand/fp/no-mutation + hasForwardedRef.current = true; + navigate(`/${UserFlow.Continue}/${establishableMethod}`, { + replace: true, + state: { interactionEvent: InteractionEvent.SignIn }, + }); + return; + } + + setIsForwarding(false); + }, [authenticationContext, isLoading, methods, navigate, selectMethod]); + + // The guard renders the invalid-session page when there is no context. + if (isLoading || !authenticationContext || isForwarding) { + return null; + } + + if (methods.length > 0) { + return ( + + + + ); + } + + if (authenticationContext.subjectProofConnectors.length > 0) { + return ( + + + + ); + } + + return ( + + ); +}; + +export default StepUp; diff --git a/packages/experience/src/utils/step-up.test.ts b/packages/experience/src/utils/step-up.test.ts new file mode 100644 index 000000000000..5c4b27755044 --- /dev/null +++ b/packages/experience/src/utils/step-up.test.ts @@ -0,0 +1,294 @@ +import { MfaFactor, VerificationType } from '@logto/schemas'; +import { noop } from '@silverhand/essentials'; + +import { + getDisplayedStepUpMethods, + getMaskedIdentifier, + isStepUpMethod, + stepUpMethodToMfaFactor, + stepUpMethods, + toMfaFlowState, +} from './step-up'; + +const email = 'f***@logto.io'; +const phone = '+1******1234'; + +const nativeSdk: NonNullable = { + platform: 'ios', + callbackLink: 'io.logto://callback', + getPostMessage: () => noop, + supportedConnector: { universal: false, nativeTargets: [] }, +}; + +const setNativeSdk = (value: Window['logtoNativeSdk']) => { + // eslint-disable-next-line @silverhand/fp/no-mutation -- tests stage the native SDK global + window.logtoNativeSdk = value; +}; + +describe('stepUpMethods', () => { + it('recognizes exactly the pinned-user first factors and the enrolled MFA factors', () => { + expect([...stepUpMethods]).toEqual([ + VerificationType.Password, + VerificationType.EmailVerificationCode, + VerificationType.PhoneVerificationCode, + VerificationType.TOTP, + VerificationType.WebAuthn, + VerificationType.BackupCode, + VerificationType.MfaEmailVerificationCode, + VerificationType.MfaPhoneVerificationCode, + ]); + + for (const method of stepUpMethods) { + expect(isStepUpMethod(method)).toBe(true); + } + + for (const type of [ + VerificationType.Social, + VerificationType.EnterpriseSso, + VerificationType.SignInPasskey, + VerificationType.NewPasswordIdentity, + VerificationType.OneTimeToken, + ]) { + expect(isStepUpMethod(type)).toBe(false); + } + }); + + it('maps only the MFA methods to the factor of their `/mfa-verification` page', () => { + expect(stepUpMethodToMfaFactor).toEqual({ + [VerificationType.TOTP]: MfaFactor.TOTP, + [VerificationType.WebAuthn]: MfaFactor.WebAuthn, + [VerificationType.BackupCode]: MfaFactor.BackupCode, + [VerificationType.MfaEmailVerificationCode]: MfaFactor.EmailVerificationCode, + [VerificationType.MfaPhoneVerificationCode]: MfaFactor.PhoneVerificationCode, + }); + expect(stepUpMethodToMfaFactor[VerificationType.Password]).toBeUndefined(); + expect(stepUpMethodToMfaFactor[VerificationType.EmailVerificationCode]).toBeUndefined(); + expect(stepUpMethodToMfaFactor[VerificationType.PhoneVerificationCode]).toBeUndefined(); + }); +}); + +describe('getDisplayedStepUpMethods', () => { + afterEach(() => { + setNativeSdk(undefined); + }); + + it('keeps every step-up method in the order the server sent', () => { + const serverOrder = [ + VerificationType.BackupCode, + VerificationType.MfaPhoneVerificationCode, + VerificationType.Password, + VerificationType.WebAuthn, + VerificationType.EmailVerificationCode, + VerificationType.TOTP, + VerificationType.MfaEmailVerificationCode, + VerificationType.PhoneVerificationCode, + ]; + + expect(getDisplayedStepUpMethods(serverOrder)).toEqual(serverOrder); + }); + + it('drops the verification types the step-up screens cannot render', () => { + expect( + getDisplayedStepUpMethods([ + VerificationType.Social, + VerificationType.Password, + VerificationType.OneTimeToken, + VerificationType.EnterpriseSso, + VerificationType.EmailVerificationCode, + VerificationType.SignInPasskey, + VerificationType.NewPasswordIdentity, + ]) + ).toEqual([VerificationType.Password, VerificationType.EmailVerificationCode]); + }); + + it('returns an empty list when the server offers nothing the screens know', () => { + expect(getDisplayedStepUpMethods([])).toEqual([]); + expect( + getDisplayedStepUpMethods([VerificationType.Social, VerificationType.OneTimeToken]) + ).toEqual([]); + }); + + it('keeps WebAuthn alongside other methods outside a native webview', () => { + expect( + getDisplayedStepUpMethods([ + VerificationType.WebAuthn, + VerificationType.Password, + VerificationType.TOTP, + ]) + ).toEqual([VerificationType.WebAuthn, VerificationType.Password, VerificationType.TOTP]); + }); + + it.each(['ios', 'android'] as const)( + 'drops WebAuthn on a native %s webview when another method remains', + (platform) => { + setNativeSdk({ ...nativeSdk, platform }); + + expect( + getDisplayedStepUpMethods([ + VerificationType.WebAuthn, + VerificationType.Password, + VerificationType.TOTP, + ]) + ).toEqual([VerificationType.Password, VerificationType.TOTP]); + } + ); + + it('keeps WebAuthn on a native webview when it is the only method', () => { + setNativeSdk(nativeSdk); + + expect(getDisplayedStepUpMethods([VerificationType.WebAuthn])).toEqual([ + VerificationType.WebAuthn, + ]); + }); + + it('keeps WebAuthn on a native webview when the other server methods are not renderable', () => { + setNativeSdk(nativeSdk); + + expect(getDisplayedStepUpMethods([VerificationType.Social, VerificationType.WebAuthn])).toEqual( + [VerificationType.WebAuthn] + ); + }); + + it('does not treat an unknown platform as a native webview', () => { + // @ts-expect-error -- an SDK global whose platform the SPA does not know + setNativeSdk({ ...nativeSdk, platform: 'web' }); + + expect( + getDisplayedStepUpMethods([VerificationType.WebAuthn, VerificationType.Password]) + ).toEqual([VerificationType.WebAuthn, VerificationType.Password]); + }); +}); + +describe('getMaskedIdentifier', () => { + const maskedIdentifiers = { email, phone }; + + it.each([ + VerificationType.EmailVerificationCode, + VerificationType.MfaEmailVerificationCode, + ] as const)('returns the masked email for %s', (method) => { + expect(getMaskedIdentifier(method, maskedIdentifiers)).toBe(email); + }); + + it.each([ + VerificationType.PhoneVerificationCode, + VerificationType.MfaPhoneVerificationCode, + ] as const)('returns the masked phone for %s', (method) => { + expect(getMaskedIdentifier(method, maskedIdentifiers)).toBe(phone); + }); + + it.each([ + VerificationType.Password, + VerificationType.TOTP, + VerificationType.WebAuthn, + VerificationType.BackupCode, + ] as const)('returns nothing for %s even when identifiers are available', (method) => { + expect(getMaskedIdentifier(method, maskedIdentifiers)).toBeUndefined(); + }); + + it('returns nothing when the server did not provide the identifier of a code method', () => { + expect(getMaskedIdentifier(VerificationType.EmailVerificationCode, { phone })).toBeUndefined(); + expect( + getMaskedIdentifier(VerificationType.MfaPhoneVerificationCode, { email }) + ).toBeUndefined(); + expect(getMaskedIdentifier(VerificationType.PhoneVerificationCode, {})).toBeUndefined(); + }); +}); + +describe('toMfaFlowState', () => { + it('maps the MFA methods to factors in order and ignores the first factors', () => { + expect( + toMfaFlowState( + [ + VerificationType.Password, + VerificationType.BackupCode, + VerificationType.EmailVerificationCode, + VerificationType.MfaPhoneVerificationCode, + VerificationType.TOTP, + VerificationType.PhoneVerificationCode, + VerificationType.WebAuthn, + VerificationType.MfaEmailVerificationCode, + ], + { maskedIdentifiers: {} } + ) + ).toEqual({ + availableFactors: [ + MfaFactor.BackupCode, + MfaFactor.PhoneVerificationCode, + MfaFactor.TOTP, + MfaFactor.WebAuthn, + MfaFactor.EmailVerificationCode, + ], + maskedIdentifiers: {}, + }); + }); + + it('yields no factors when only first factors are offered', () => { + expect( + toMfaFlowState( + [ + VerificationType.Password, + VerificationType.EmailVerificationCode, + VerificationType.PhoneVerificationCode, + ], + { maskedIdentifiers: { email, phone } } + ) + ).toEqual({ availableFactors: [], maskedIdentifiers: {} }); + + expect(toMfaFlowState([], { maskedIdentifiers: { email, phone } })).toEqual({ + availableFactors: [], + maskedIdentifiers: {}, + }); + }); + + it('keys the masked identifiers by the enrolled code factors', () => { + expect( + toMfaFlowState( + [VerificationType.MfaEmailVerificationCode, VerificationType.MfaPhoneVerificationCode], + { maskedIdentifiers: { email, phone } } + ) + ).toEqual({ + availableFactors: [MfaFactor.EmailVerificationCode, MfaFactor.PhoneVerificationCode], + maskedIdentifiers: { + [MfaFactor.EmailVerificationCode]: email, + [MfaFactor.PhoneVerificationCode]: phone, + }, + }); + }); + + it('omits the identifier of a code factor that is not offered', () => { + expect( + toMfaFlowState([VerificationType.TOTP, VerificationType.MfaEmailVerificationCode], { + maskedIdentifiers: { email, phone }, + }) + ).toEqual({ + availableFactors: [MfaFactor.TOTP, MfaFactor.EmailVerificationCode], + maskedIdentifiers: { [MfaFactor.EmailVerificationCode]: email }, + }); + }); + + it('does not attribute a primary code identifier to an MFA factor', () => { + expect( + toMfaFlowState([VerificationType.EmailVerificationCode, VerificationType.TOTP], { + maskedIdentifiers: { email }, + }) + ).toEqual({ availableFactors: [MfaFactor.TOTP], maskedIdentifiers: {} }); + }); + + it('omits the identifier of an offered code factor when the server did not provide one', () => { + expect( + toMfaFlowState( + [VerificationType.MfaEmailVerificationCode, VerificationType.MfaPhoneVerificationCode], + { maskedIdentifiers: { phone } } + ) + ).toEqual({ + availableFactors: [MfaFactor.EmailVerificationCode, MfaFactor.PhoneVerificationCode], + maskedIdentifiers: { [MfaFactor.PhoneVerificationCode]: phone }, + }); + + expect( + toMfaFlowState([VerificationType.MfaEmailVerificationCode], { + maskedIdentifiers: { email: '' }, + }) + ).toEqual({ availableFactors: [MfaFactor.EmailVerificationCode], maskedIdentifiers: {} }); + }); +}); diff --git a/packages/experience/src/utils/step-up.ts b/packages/experience/src/utils/step-up.ts new file mode 100644 index 000000000000..5a45654d5946 --- /dev/null +++ b/packages/experience/src/utils/step-up.ts @@ -0,0 +1,115 @@ +import { + type InteractionAuthenticationContext, + type MaskedIdentifiers, + MfaFactor, + VerificationType, +} from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; + +import { type MfaFlowState } from '@/types/guard'; + +import { isNativeWebview } from './native-sdk'; + +/** + * The verification types the step-up screens can render: the pinned-user first factors and the + * enrolled MFA factors. Core decides which of them are offered; the SPA never computes + * eligibility, it only renders what `availableMethods` carries. + */ +export const stepUpMethods = Object.freeze([ + VerificationType.Password, + VerificationType.EmailVerificationCode, + VerificationType.PhoneVerificationCode, + VerificationType.TOTP, + VerificationType.WebAuthn, + VerificationType.BackupCode, + VerificationType.MfaEmailVerificationCode, + VerificationType.MfaPhoneVerificationCode, +] as const); + +export type StepUpMethod = (typeof stepUpMethods)[number]; + +const stepUpMethodSet: ReadonlySet = new Set(stepUpMethods); + +export const isStepUpMethod = (type: VerificationType): type is StepUpMethod => + stepUpMethodSet.has(type); + +/** The MFA factor a step-up method verifies through the existing `/mfa-verification` pages. */ +export const stepUpMethodToMfaFactor: Readonly>> = + Object.freeze({ + [VerificationType.TOTP]: MfaFactor.TOTP, + [VerificationType.WebAuthn]: MfaFactor.WebAuthn, + [VerificationType.BackupCode]: MfaFactor.BackupCode, + [VerificationType.MfaEmailVerificationCode]: MfaFactor.EmailVerificationCode, + [VerificationType.MfaPhoneVerificationCode]: MfaFactor.PhoneVerificationCode, + }); + +/** + * The methods the screens render, in the server's order. Only the types the screens know are + * kept; WebAuthn is dropped on a native webview when the user has another option, since the + * webview cannot run the ceremony. This is a client capability, not an eligibility rule. + */ +export const getDisplayedStepUpMethods = ( + availableMethods: readonly VerificationType[] +): StepUpMethod[] => { + const methods = availableMethods.filter((method): method is StepUpMethod => + isStepUpMethod(method) + ); + + return isNativeWebview() && methods.length > 1 + ? methods.filter((method) => method !== VerificationType.WebAuthn) + : methods; +}; + +/** The masked identifier a code method sends to, from the server-provided masked identifiers. */ +export const getMaskedIdentifier = ( + method: StepUpMethod, + { email, phone }: MaskedIdentifiers +): string | undefined => { + switch (method) { + case VerificationType.EmailVerificationCode: + case VerificationType.MfaEmailVerificationCode: { + return email; + } + case VerificationType.PhoneVerificationCode: + case VerificationType.MfaPhoneVerificationCode: { + return phone; + } + default: { + return undefined; + } + } +}; + +/** + * The flow state the existing MFA verification pages expect, built from the displayed methods: + * the enrolled factors Core offers, with the masked identifiers keyed the way those pages read + * them. Until the MFA pages read the server context themselves, this is what they receive + * through `location.state`. + */ +export const toMfaFlowState = ( + methods: readonly StepUpMethod[], + { maskedIdentifiers }: Pick +): MfaFlowState => { + const availableFactors = methods + .map((method) => stepUpMethodToMfaFactor[method]) + .filter((factor): factor is MfaFactor => factor !== undefined); + const { email, phone } = maskedIdentifiers; + const maskedFactorIdentifiers = { + ...conditional( + availableFactors.includes(MfaFactor.EmailVerificationCode) && + email && { [MfaFactor.EmailVerificationCode]: email } + ), + ...conditional( + availableFactors.includes(MfaFactor.PhoneVerificationCode) && + phone && { [MfaFactor.PhoneVerificationCode]: phone } + ), + }; + + return { + availableFactors, + // The MFA flow state guard infers a full record, while only the enrolled code factors carry + // an identifier; the MFA pages read it per factor, so a partial record is what they expect. + // eslint-disable-next-line no-restricted-syntax + maskedIdentifiers: maskedFactorIdentifiers as MfaFlowState['maskedIdentifiers'], + }; +}; diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 772673c522d7..8c24dff15420 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -233,6 +233,39 @@ export default class MockClient { return this.logto.handleSignInCallback(signInCallbackUri); } + /** + * Complete the authorization a finished interaction handed back, from a single request. + * + * A resumed request either asks for consent once more — a 303 to the consent page, which + * `consent()` drives — or reuses the existing grant and goes straight back to the client + * callback. `processSession()` and `manualConsent()` each assume one of those shapes, and + * re-requesting the authorization URL after it already handed back a callback consumes it a + * second time. + */ + public async resumeAuthorization(redirectTo: string) { + const authCodeResponse = await ky.get(redirectTo, { + headers: { + cookie: this.getCookieHeader(new URL(redirectTo).pathname), + }, + redirect: 'manual', + throwHttpErrors: false, + }); + const location = authCodeResponse.headers.get('location'); + + assert( + authCodeResponse.status === 303 && location, + new Error(`Resume authorization failed: ${authCodeResponse.status} ${location ?? ''}`) + ); + + if (location.startsWith('/consent')) { + this.mergeRawCookies(authCodeResponse.headers.getSetCookie()); + + return this.logto.handleSignInCallback(await this.consent()); + } + + return this.logto.handleSignInCallback(location); + } + public async getAccessToken(resource?: string, organizationId?: string) { return this.logto.getAccessToken(resource, organizationId); } diff --git a/packages/integration-tests/src/helpers/experience/authorization.ts b/packages/integration-tests/src/helpers/experience/authorization.ts index ba1a518249a7..cbe943f380ab 100644 --- a/packages/integration-tests/src/helpers/experience/authorization.ts +++ b/packages/integration-tests/src/helpers/experience/authorization.ts @@ -10,13 +10,17 @@ import { parseInteractionCookie } from '#src/utils.js'; * Start an authorization on the client's current cookie jar (so an existing OIDC session is * presented) and merge the cookies it sets, so the client can continue with the interaction the * authorization started. + * + * `redirectUri` defaults to the demo app callback; a client built on another application must pass + * the callback it registered. */ export const authorizeWithSession = async ( client: ExperienceClient, - options: Omit = {} + options: Omit = {}, + redirectUri = demoAppRedirectUri ) => { const response = await client.startAuthorization( - demoAppRedirectUri, + redirectUri, options, client.getCookieHeader('/oidc/auth') ); diff --git a/packages/integration-tests/src/tests/api/experience-api/step-up-interaction/submission.test.ts b/packages/integration-tests/src/tests/api/experience-api/step-up-interaction/submission.test.ts new file mode 100644 index 000000000000..fe1213e873aa --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/step-up-interaction/submission.test.ts @@ -0,0 +1,383 @@ +/** + * @fileoverview Pure step-up submission: the allow-list completion path, its audit entry, and the + * token claims it produces, asserted end to end through a real Authorization Code Flow on a + * developer-created application. + * + * SignIn with requested ACR is a different path (its requested class is a completion requirement of + * `submit()`), and establishing or enrolling a missing method inside step-up is a later milestone; + * neither is covered here. + */ +import { TemplateType } from '@logto/connector-kit'; +import { Prompt } from '@logto/node'; +import { + ApplicationType, + ConnectorType, + InteractionEvent, + MfaFactor, + SignInIdentifier, + VerificationType, + type Application, +} from '@logto/schemas'; +import { authenticator } from 'otplib'; + +import { createUserMfaVerification, getUser, updateUserLogtoConfig } from '#src/api/admin-user.js'; +import { createApplication, deleteApplication } from '#src/api/application.js'; +import { getAuditLogs } from '#src/api/logs.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { type ExperienceClient } from '#src/client/experience/index.js'; +import { initExperienceClient } from '#src/helpers/client.js'; +import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js'; +import { authorizeWithSession } from '#src/helpers/experience/authorization.js'; +import { successfullyVerifyTotp } from '#src/helpers/experience/totp-verification.js'; +import { expectRejects, readConnectorMessage } from '#src/helpers/index.js'; +import { + enableAllPasswordSignInMethods, + enableMandatoryMfaWithTotp, + enableUserControlledMfaWithNoPrompt, + resetMfaSettings, +} from '#src/helpers/sign-in-experience.js'; +import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js'; +import { devFeatureTest, generateTestName } from '#src/utils.js'; + +const firstFactorAcr = 'urn:logto:acr:1fa'; +const mfaAcr = 'urn:logto:acr:mfa'; +const redirectUri = 'https://step-up.example.com/callback'; +const stepUpLogKey = 'Interaction.SignIn.StepUp.Submit'; + +/** + * Submit the step-up and exchange the authorization code it hands back. The resumed authorization + * either goes straight to the client callback or asks for consent once more, depending on whether + * the request forced reauthentication. + */ +const submitStepUp = async (client: ExperienceClient) => { + const { redirectTo } = await client.submitInteraction(); + await client.resumeAuthorization(redirectTo); + + return client.getIdTokenClaims(); +}; + +/** Start a step-up authorization on the signed-in client and land on the step-up path. */ +const startStepUp = async ( + client: ExperienceClient, + { acrValues, maxAge, prompt }: { acrValues: string; maxAge?: string; prompt?: Prompt } +) => { + const { status, location } = await authorizeWithSession( + client, + { + ...(prompt ? { prompt } : {}), + extraParams: { + acr_values: acrValues, + ...(maxAge === undefined ? {} : { max_age: maxAge }), + }, + }, + redirectUri + ); + + expect(status).toBe(303); + expect(location.startsWith('/step-up')).toBe(true); + await expect( + client.initInteraction({ interactionEvent: InteractionEvent.SignIn }) + ).resolves.toBeUndefined(); +}; + +devFeatureTest.describe('pure step-up submission', () => { + const userApi = new UserApiTest(); + /** A password user with a primary email and no enrolled factor. */ + const passwordUser = generateNewUserProfile({ + username: true, + password: true, + primaryEmail: true, + }); + + // eslint-disable-next-line @silverhand/fp/no-let -- Assigned by the fixtures. + let application: Application; + // eslint-disable-next-line @silverhand/fp/no-let + let passwordUserId = ''; + + const createClient = async () => + initExperienceClient({ + config: { appId: application.id, scopes: [] }, + redirectUri, + }); + + /** + * A fresh user with a password and an enrolled TOTP factor. TOTP codes are single-use per time + * step, so every test that verifies one enrolls its own account instead of sharing a secret. + */ + const createTotpUser = async () => { + const profile = generateNewUserProfile({ username: true, password: true, primaryEmail: true }); + const user = await userApi.create(profile); + const totp = await createUserMfaVerification(user.id, MfaFactor.TOTP); + + if (totp.type !== MfaFactor.TOTP) { + throw new Error('unexpected MFA factor type'); + } + + // Let the password sign-in establish the session without a TOTP challenge. A requested `mfa` + // in the step-up ignores this preference, so the factor still has to be verified there. + await updateUserLogtoConfig(user.id, { + mfa: { skipMfaOnSignIn: true }, + passkeySignIn: {}, + }); + + return { profile, id: user.id, code: authenticator.generate(totp.secret) }; + }; + + /** Sign in with the password through the created application, leaving an OIDC session behind. */ + const signInWithPassword = async ({ username, password }: typeof passwordUser) => { + const client = await createClient(); + const { verificationId } = await client.verifyPassword({ + identifier: { type: SignInIdentifier.Username, value: username }, + password, + }); + await client.identifyUser({ verificationId }); + + const { redirectTo } = await client.submitInteraction(); + await client.processSession(redirectTo); + + return client; + }; + + const verifyPassword = async (client: ExperienceClient, { password }: typeof passwordUser) => { + const { verificationId } = await client.verifyPassword({ password }); + await client.identifyUser({ verificationId }); + }; + + beforeAll(async () => { + await enableAllPasswordSignInMethods(); + await updateSignInExperience({ adaptiveMfa: { enabled: false } }); + // TOTP enabled without an enrollment prompt, so a user without a factor can still sign in and a + // requested `mfa` is what asks for the factor. + await enableUserControlledMfaWithNoPrompt(); + await clearConnectorsByTypes([ConnectorType.Email]); + await setEmailConnector(); + + const [createdPasswordUser, createdApplication] = await Promise.all([ + userApi.create(passwordUser), + createApplication(generateTestName(), ApplicationType.SPA, { + oidcClientMetadata: { redirectUris: [redirectUri], postLogoutRedirectUris: [] }, + }), + ]); + // eslint-disable-next-line @silverhand/fp/no-mutation + passwordUserId = createdPasswordUser.id; + // eslint-disable-next-line @silverhand/fp/no-mutation + application = createdApplication; + }); + + afterAll(async () => { + await resetMfaSettings(); + await clearConnectorsByTypes([ConnectorType.Email]); + await Promise.all([userApi.cleanUp(), deleteApplication(application.id)]); + }); + + it('reaches 1fa through the pinned password without running a sign-in', async () => { + const client = await signInWithPassword(passwordUser); + const { lastSignInAt } = await getUser(passwordUserId); + const issuedAt = Math.floor(Date.now() / 1000); + + await startStepUp(client, { acrValues: firstFactorAcr, prompt: Prompt.Login }); + + const { authenticationContext } = await client.getInteractionData(); + expect(authenticationContext).toMatchObject({ + mode: 'stepUp', + requestedAcrValues: [firstFactorAcr], + selectedAcr: firstFactorAcr, + }); + expect(authenticationContext?.availableMethods).toEqual([ + VerificationType.Password, + VerificationType.EmailVerificationCode, + ]); + + await verifyPassword(client, passwordUser); + const claims = await submitStepUp(client); + + expect(claims).toMatchObject({ sub: passwordUserId, acr: firstFactorAcr, amr: ['pwd'] }); + expect(claims.auth_time).toBeGreaterThanOrEqual(issuedAt); + // A step-up is not a sign-in: no `lastSignInAt` write, no post-sign-in side effect. + const { lastSignInAt: afterStepUp } = await getUser(passwordUserId); + expect(afterStepUp).toBe(lastSignInAt); + }); + + it('reaches 1fa through the pinned primary email code and reports only its own amr', async () => { + const client = await signInWithPassword(passwordUser); + await startStepUp(client, { acrValues: firstFactorAcr, prompt: Prompt.Login }); + + const { verificationId } = await client.sendVerificationCode({ + identifier: { type: SignInIdentifier.Email }, + interactionEvent: InteractionEvent.SignIn, + }); + const { code, address, type } = await readConnectorMessage('Email'); + + expect(address).toBe(passwordUser.primaryEmail); + expect(type).toBe(TemplateType.SignIn); + + await client.verifyVerificationCode({ + identifier: { type: SignInIdentifier.Email }, + verificationId, + code, + }); + await client.identifyUser({ verificationId }); + + const claims = await submitStepUp(client); + + // The password session only carried the context in; `amr` describes this interaction alone. + expect(claims).toMatchObject({ sub: passwordUserId, acr: firstFactorAcr, amr: ['otp'] }); + }); + + it('reaches mfa by verifying only the enrolled factor on a 1fa session', async () => { + const { profile, id, code } = await createTotpUser(); + const client = await signInWithPassword(profile); + await startStepUp(client, { acrValues: mfaAcr }); + + const { authenticationContext } = await client.getInteractionData(); + expect(authenticationContext).toMatchObject({ + selectedAcr: mfaAcr, + // The session's `1fa` skips the first-factor prompt for a user with a factor to verify. + availableMethods: [VerificationType.TOTP], + }); + + await successfullyVerifyTotp(client, { code }); + const claims = await submitStepUp(client); + + expect(claims).toMatchObject({ sub: id, acr: mfaAcr, amr: ['otp', 'mfa'] }); + }); + + it('rejects a submission that reaches only 1fa when mfa was selected, then completes it', async () => { + const { profile, id, code } = await createTotpUser(); + const client = await signInWithPassword(profile); + await startStepUp(client, { acrValues: mfaAcr }); + await verifyPassword(client, profile); + + // The UI only offers sufficient methods; a client that submits anyway writes no result. + await expectRejects(client.submitInteraction(), { + code: 'session.step_up.acr_not_satisfied', + status: 403, + }); + + // The rejected submission leaves the interaction usable: the missing factor completes it. + await successfullyVerifyTotp(client, { code }); + const claims = await submitStepUp(client); + + expect(claims).toMatchObject({ sub: id, acr: mfaAcr, amr: ['pwd', 'otp', 'mfa'] }); + }); + + it('forces an active verification without forcing the password', async () => { + const { profile, id, code } = await createTotpUser(); + const client = await signInWithPassword(profile); + + // The session already satisfies `1fa`; `max_age=0` is what demands a fresh verification, and it + // does not demand a first factor: the enrolled factor alone completes the interaction. + await startStepUp(client, { acrValues: firstFactorAcr, maxAge: '0' }); + await successfullyVerifyTotp(client, { code }); + const claims = await submitStepUp(client); + + // The achieved class may be stronger than the selected one: the factor pairs with the `1fa` + // context the session carried in. + expect(claims).toMatchObject({ sub: id, acr: mfaAcr, amr: ['otp', 'mfa'] }); + }); + + it('replaces the session context instead of merging it', async () => { + const { profile, id, code } = await createTotpUser(); + const client = await signInWithPassword(profile); + await startStepUp(client, { acrValues: mfaAcr }); + await successfullyVerifyTotp(client, { code }); + + const elevated = await submitStepUp(client); + expect(elevated).toMatchObject({ acr: mfaAcr, amr: ['otp', 'mfa'] }); + + // A later step-up that only re-verifies the password drops the previous MFA information. + await startStepUp(client, { acrValues: firstFactorAcr, prompt: Prompt.Login }); + await verifyPassword(client, profile); + const reauthenticated = await submitStepUp(client); + + expect(reauthenticated).toMatchObject({ sub: id, acr: firstFactorAcr, amr: ['pwd'] }); + }); + + it('applies no tenant MFA policy', async () => { + const { profile, id } = await createTotpUser(); + const client = await signInWithPassword(profile); + await enableMandatoryMfaWithTotp(); + + try { + await startStepUp(client, { acrValues: firstFactorAcr, prompt: Prompt.Login }); + await verifyPassword(client, profile); + + // A mandatory MFA policy never reaches step-up; the requested class alone decides. + const claims = await submitStepUp(client); + expect(claims).toMatchObject({ sub: id, acr: firstFactorAcr, amr: ['pwd'] }); + } finally { + await enableUserControlledMfaWithNoPrompt(); + } + }); + + it('recovers the same context when the client refetches mid-flow', async () => { + const client = await signInWithPassword(passwordUser); + await startStepUp(client, { acrValues: firstFactorAcr, prompt: Prompt.Login }); + const { verificationId } = await client.verifyPassword({ password: passwordUser.password }); + + // A refresh is a plain refetch: the server-driven context and the verified record survive it. + const refreshed = await client.getInteractionData(); + expect(refreshed.authenticationContext).toMatchObject({ + mode: 'stepUp', + selectedAcr: firstFactorAcr, + }); + expect(refreshed.verificationRecords).toEqual([ + expect.objectContaining({ id: verificationId, verified: true }), + ]); + expect(refreshed.userId).toBeUndefined(); + + await client.identifyUser({ verificationId }); + const claims = await submitStepUp(client); + expect(claims).toMatchObject({ sub: passwordUserId, acr: firstFactorAcr, amr: ['pwd'] }); + }); + + it('records successful and rejected submissions under the step-up audit key without secrets', async () => { + const { profile } = await createTotpUser(); + const rejectedClient = await signInWithPassword(profile); + await startStepUp(rejectedClient, { acrValues: mfaAcr }); + await verifyPassword(rejectedClient, profile); + await expectRejects(rejectedClient.submitInteraction(), { + code: 'session.step_up.acr_not_satisfied', + status: 403, + }); + + const acceptedClient = await signInWithPassword(passwordUser); + await startStepUp(acceptedClient, { acrValues: firstFactorAcr, prompt: Prompt.Login }); + const { verificationId } = await acceptedClient.verifyPassword({ + password: passwordUser.password, + }); + await acceptedClient.identifyUser({ verificationId }); + await submitStepUp(acceptedClient); + + const logs = await getAuditLogs( + new URLSearchParams({ logKey: stepUpLogKey, applicationId: application.id }) + ); + const accepted = logs.find(({ payload }) => payload.result === 'Success'); + const rejected = logs.find(({ payload }) => payload.result === 'Error'); + + expect(accepted?.payload).toMatchObject({ + key: stepUpLogKey, + applicationId: application.id, + requestedAcrValues: [firstFactorAcr], + selectedAcr: firstFactorAcr, + achievedAcr: firstFactorAcr, + factors: ['password'], + }); + expect(rejected?.payload).toMatchObject({ + key: stepUpLogKey, + requestedAcrValues: [mfaAcr], + selectedAcr: mfaAcr, + achievedAcr: firstFactorAcr, + factors: ['password'], + error: { code: 'session.step_up.acr_not_satisfied' }, + }); + + // Credentials never reach the log. The interaction snapshot the submit route appends carries + // the verification record ids, which are not credentials. + for (const log of [accepted, rejected]) { + const serialized = JSON.stringify(log); + expect(serialized).not.toContain(passwordUser.password); + expect(serialized).not.toContain(profile.password); + } + }); +}); diff --git a/packages/phrases-experience/src/locales/ar/index.ts b/packages/phrases-experience/src/locales/ar/index.ts index 4434fc4070ad..b28f5fc9eca9 100644 --- a/packages/phrases-experience/src/locales/ar/index.ts +++ b/packages/phrases-experience/src/locales/ar/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const ar = { @@ -29,6 +30,7 @@ const ar = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/ar/step-up.ts b/packages/phrases-experience/src/locales/ar/step-up.ts new file mode 100644 index 000000000000..6713f8f4b50b --- /dev/null +++ b/packages/phrases-experience/src/locales/ar/step-up.ts @@ -0,0 +1,15 @@ +const step_up = { + verify_your_identity: 'تحقق من هويتك', + choose_method_description: 'للمتابعة، أكّد أنك أنت باستخدام إحدى الطرق التالية.', + password: 'كلمة المرور', + password_description: 'أدخل كلمة مرور حسابك', + enter_password_description: 'أدخل كلمة مرور حسابك للمتابعة.', + enter_verification_code_description: 'أدخل رمز التحقق المُرسل إلى {{identifier}}.', + subject_proof_description: + 'للمتابعة، أكّد أنك أنت من خلال حساب مرتبط بحسابك. يمكنك بعد ذلك إعداد طريقة تحقق.', + no_method_available: 'لا توجد طريقة تحقق متاحة', + no_method_available_description: + 'لا يحتوي حسابك على أي طريقة يمكنها إكمال هذا التحقق. يرجى التواصل مع المسؤول للحصول على المساعدة.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/cs/index.ts b/packages/phrases-experience/src/locales/cs/index.ts index f40aa92bdd2f..7df00cfc7f99 100644 --- a/packages/phrases-experience/src/locales/cs/index.ts +++ b/packages/phrases-experience/src/locales/cs/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const cs = { @@ -29,6 +30,7 @@ const cs = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/cs/step-up.ts b/packages/phrases-experience/src/locales/cs/step-up.ts new file mode 100644 index 000000000000..b36a9a42e022 --- /dev/null +++ b/packages/phrases-experience/src/locales/cs/step-up.ts @@ -0,0 +1,15 @@ +const step_up = { + verify_your_identity: 'Ověř svou identitu', + choose_method_description: 'Pro pokračování potvrď, že jsi to ty, jednou z následujících metod.', + password: 'Heslo', + password_description: 'Zadej heslo ke svému účtu', + enter_password_description: 'Pokračuj zadáním hesla ke svému účtu.', + enter_verification_code_description: 'Zadej ověřovací kód odeslaný na {{identifier}}.', + subject_proof_description: + 'Pro pokračování potvrď, že jsi to ty, pomocí účtu propojeného s tím tvým. Poté si můžeš nastavit metodu ověření.', + no_method_available: 'Není k dispozici žádná metoda ověření', + no_method_available_description: + 'Tvůj účet nemá žádnou metodu, kterou by šlo toto ověření dokončit. Požádej prosím o pomoc svého administrátora.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/de/index.ts b/packages/phrases-experience/src/locales/de/index.ts index 0ae479bca634..bbd0e2fae7ae 100644 --- a/packages/phrases-experience/src/locales/de/index.ts +++ b/packages/phrases-experience/src/locales/de/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const de = { @@ -29,6 +30,7 @@ const de = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/de/step-up.ts b/packages/phrases-experience/src/locales/de/step-up.ts new file mode 100644 index 000000000000..c9c6389827d4 --- /dev/null +++ b/packages/phrases-experience/src/locales/de/step-up.ts @@ -0,0 +1,17 @@ +const step_up = { + verify_your_identity: 'Bestätige deine Identität', + choose_method_description: + 'Um fortzufahren, bestätige mit einer der folgenden Methoden, dass du es bist.', + password: 'Passwort', + password_description: 'Gib das Passwort deines Kontos ein', + enter_password_description: 'Gib zum Fortfahren das Passwort deines Kontos ein.', + enter_verification_code_description: + 'Gib den Verifizierungscode ein, der an {{identifier}} gesendet wurde.', + subject_proof_description: + 'Um fortzufahren, bestätige über ein mit deinem Konto verknüpftes Konto, dass du es bist. Anschließend kannst du eine Verifizierungsmethode einrichten.', + no_method_available: 'Keine Verifizierungsmethode verfügbar', + no_method_available_description: + 'Dein Konto hat keine Methode, mit der diese Verifizierung abgeschlossen werden kann. Bitte wende dich an deinen Administrator, um Hilfe zu erhalten.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/en/index.ts b/packages/phrases-experience/src/locales/en/index.ts index ba0fedeeeaed..16da86685909 100644 --- a/packages/phrases-experience/src/locales/en/index.ts +++ b/packages/phrases-experience/src/locales/en/index.ts @@ -9,6 +9,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const en = { @@ -25,6 +26,7 @@ const en = { profile, account_center, passkey_sign_in, + step_up, }, }; diff --git a/packages/phrases-experience/src/locales/en/step-up.ts b/packages/phrases-experience/src/locales/en/step-up.ts new file mode 100644 index 000000000000..9f8a1e1ecdf6 --- /dev/null +++ b/packages/phrases-experience/src/locales/en/step-up.ts @@ -0,0 +1,15 @@ +const step_up = { + verify_your_identity: 'Verify your identity', + choose_method_description: 'To continue, confirm it’s you with one of the following methods.', + password: 'Password', + password_description: 'Enter your account password', + enter_password_description: 'Enter your account password to continue.', + enter_verification_code_description: 'Enter the verification code sent to {{identifier}}.', + subject_proof_description: + 'To continue, confirm it’s you through an account linked to yours. You can then set up a verification method.', + no_method_available: 'No verification method available', + no_method_available_description: + 'Your account has no method that can complete this verification. Please contact your administrator for assistance.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/es-mx/index.ts b/packages/phrases-experience/src/locales/es-mx/index.ts index 1bec63c4983a..7bed8972f386 100644 --- a/packages/phrases-experience/src/locales/es-mx/index.ts +++ b/packages/phrases-experience/src/locales/es-mx/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const esMX = { @@ -29,6 +30,7 @@ const esMX = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/es-mx/step-up.ts b/packages/phrases-experience/src/locales/es-mx/step-up.ts new file mode 100644 index 000000000000..435c7da9becc --- /dev/null +++ b/packages/phrases-experience/src/locales/es-mx/step-up.ts @@ -0,0 +1,17 @@ +const step_up = { + verify_your_identity: 'Verifique su identidad', + choose_method_description: + 'Para continuar, confirme que es usted con uno de los siguientes métodos.', + password: 'Contraseña', + password_description: 'Ingrese la contraseña de su cuenta', + enter_password_description: 'Ingrese la contraseña de su cuenta para continuar.', + enter_verification_code_description: + 'Ingrese el código de verificación enviado a {{identifier}}.', + subject_proof_description: + 'Para continuar, confirme que es usted a través de una cuenta vinculada a la suya. Después podrá configurar un método de verificación.', + no_method_available: 'No hay ningún método de verificación disponible', + no_method_available_description: + 'Su cuenta no tiene ningún método que pueda completar esta verificación. Por favor, contacte a su administrador para obtener ayuda.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/es/index.ts b/packages/phrases-experience/src/locales/es/index.ts index 4b4f8555f364..a250d8d411dd 100644 --- a/packages/phrases-experience/src/locales/es/index.ts +++ b/packages/phrases-experience/src/locales/es/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const es = { @@ -29,6 +30,7 @@ const es = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/es/step-up.ts b/packages/phrases-experience/src/locales/es/step-up.ts new file mode 100644 index 000000000000..3141804332a9 --- /dev/null +++ b/packages/phrases-experience/src/locales/es/step-up.ts @@ -0,0 +1,17 @@ +const step_up = { + verify_your_identity: 'Verifica tu identidad', + choose_method_description: + 'Para continuar, confirma que eres tú con uno de los siguientes métodos.', + password: 'Contraseña', + password_description: 'Introduce la contraseña de tu cuenta', + enter_password_description: 'Introduce la contraseña de tu cuenta para continuar.', + enter_verification_code_description: + 'Introduce el código de verificación enviado a {{identifier}}.', + subject_proof_description: + 'Para continuar, confirma que eres tú a través de una cuenta vinculada a la tuya. Después podrás configurar un método de verificación.', + no_method_available: 'No hay ningún método de verificación disponible', + no_method_available_description: + 'Tu cuenta no tiene ningún método que pueda completar esta verificación. Ponte en contacto con tu administrador para obtener ayuda.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/fa-ir/index.ts b/packages/phrases-experience/src/locales/fa-ir/index.ts index dba3d1ae08f6..aa31be4594fe 100644 --- a/packages/phrases-experience/src/locales/fa-ir/index.ts +++ b/packages/phrases-experience/src/locales/fa-ir/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const fa = { @@ -29,6 +30,7 @@ const fa = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/fa-ir/step-up.ts b/packages/phrases-experience/src/locales/fa-ir/step-up.ts new file mode 100644 index 000000000000..c79e5ecd50b9 --- /dev/null +++ b/packages/phrases-experience/src/locales/fa-ir/step-up.ts @@ -0,0 +1,15 @@ +const step_up = { + verify_your_identity: 'هویت خود را تأیید کنید', + choose_method_description: 'برای ادامه، هویت خود را با یکی از روش‌های زیر تأیید کنید.', + password: 'رمز عبور', + password_description: 'رمز عبور حساب خود را وارد کنید', + enter_password_description: 'برای ادامه، رمز عبور حساب خود را وارد کنید.', + enter_verification_code_description: 'کد تأیید ارسال‌شده به {{identifier}} را وارد کنید.', + subject_proof_description: + 'برای ادامه، هویت خود را از طریق حسابی که به حساب شما متصل است تأیید کنید. سپس می‌توانید یک روش تأیید تنظیم کنید.', + no_method_available: 'هیچ روش تأییدی در دسترس نیست', + no_method_available_description: + 'حساب شما هیچ روشی ندارد که بتواند این تأیید را انجام دهد. لطفاً برای دریافت کمک با مدیر خود تماس بگیرید.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/fr/index.ts b/packages/phrases-experience/src/locales/fr/index.ts index a97bdd3c0aa7..90ee5e799afb 100644 --- a/packages/phrases-experience/src/locales/fr/index.ts +++ b/packages/phrases-experience/src/locales/fr/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const fr = { @@ -29,6 +30,7 @@ const fr = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/fr/step-up.ts b/packages/phrases-experience/src/locales/fr/step-up.ts new file mode 100644 index 000000000000..370b86e6ad7f --- /dev/null +++ b/packages/phrases-experience/src/locales/fr/step-up.ts @@ -0,0 +1,16 @@ +const step_up = { + verify_your_identity: 'Vérifiez votre identité', + choose_method_description: + "Pour continuer, confirmez qu'il s'agit bien de vous avec l'une des méthodes suivantes.", + password: 'Mot de passe', + password_description: 'Saisissez le mot de passe de votre compte', + enter_password_description: 'Saisissez le mot de passe de votre compte pour continuer.', + enter_verification_code_description: 'Saisissez le code de vérification envoyé à {{identifier}}.', + subject_proof_description: + "Pour continuer, confirmez qu'il s'agit bien de vous via un compte lié au vôtre. Vous pourrez ensuite configurer une méthode de vérification.", + no_method_available: 'Aucune méthode de vérification disponible', + no_method_available_description: + 'Votre compte ne dispose d’aucune méthode permettant de compléter cette vérification. Veuillez contacter votre administrateur pour obtenir de l’aide.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/it/index.ts b/packages/phrases-experience/src/locales/it/index.ts index 5cc2f67dcf87..d958fab7ad48 100644 --- a/packages/phrases-experience/src/locales/it/index.ts +++ b/packages/phrases-experience/src/locales/it/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const it = { @@ -29,6 +30,7 @@ const it = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/it/step-up.ts b/packages/phrases-experience/src/locales/it/step-up.ts new file mode 100644 index 000000000000..0ddfed1fa315 --- /dev/null +++ b/packages/phrases-experience/src/locales/it/step-up.ts @@ -0,0 +1,16 @@ +const step_up = { + verify_your_identity: 'Verifica la tua identità', + choose_method_description: + 'Per continuare, conferma la tua identità con uno dei seguenti metodi.', + password: 'Password', + password_description: 'Inserisci la password del tuo account', + enter_password_description: 'Inserisci la password del tuo account per continuare.', + enter_verification_code_description: 'Inserisci il codice di verifica inviato a {{identifier}}.', + subject_proof_description: + 'Per continuare, conferma la tua identità tramite un account collegato al tuo. Potrai poi configurare un metodo di verifica.', + no_method_available: 'Nessun metodo di verifica disponibile', + no_method_available_description: + 'Il tuo account non dispone di alcun metodo in grado di completare questa verifica. Contatta il tuo amministratore per ricevere assistenza.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/ja/index.ts b/packages/phrases-experience/src/locales/ja/index.ts index aae0f7960741..1991d6533b20 100644 --- a/packages/phrases-experience/src/locales/ja/index.ts +++ b/packages/phrases-experience/src/locales/ja/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const ja = { @@ -29,6 +30,7 @@ const ja = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/ja/step-up.ts b/packages/phrases-experience/src/locales/ja/step-up.ts new file mode 100644 index 000000000000..cd66f5305ca0 --- /dev/null +++ b/packages/phrases-experience/src/locales/ja/step-up.ts @@ -0,0 +1,15 @@ +const step_up = { + verify_your_identity: '本人確認', + choose_method_description: '続行するには、次のいずれかの方法で本人確認を行ってください。', + password: 'パスワード', + password_description: 'アカウントのパスワードを入力', + enter_password_description: '続行するには、アカウントのパスワードを入力してください。', + enter_verification_code_description: '{{identifier}} に送信された確認コードを入力してください。', + subject_proof_description: + '続行するには、あなたのアカウントにリンクされたアカウントで本人確認を行ってください。その後、認証方法を設定できます。', + no_method_available: '利用可能な認証方法がありません', + no_method_available_description: + 'お使いのアカウントには、この認証を完了できる方法がありません。管理者にお問い合わせください。', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/ko/index.ts b/packages/phrases-experience/src/locales/ko/index.ts index 79b5dab33da1..6064552e9249 100644 --- a/packages/phrases-experience/src/locales/ko/index.ts +++ b/packages/phrases-experience/src/locales/ko/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const ko = { @@ -29,6 +30,7 @@ const ko = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/ko/step-up.ts b/packages/phrases-experience/src/locales/ko/step-up.ts new file mode 100644 index 000000000000..35af01632f6e --- /dev/null +++ b/packages/phrases-experience/src/locales/ko/step-up.ts @@ -0,0 +1,15 @@ +const step_up = { + verify_your_identity: '신원 확인', + choose_method_description: '계속하려면 다음 방법 중 하나로 본인임을 확인하세요.', + password: '비밀번호', + password_description: '계정 비밀번호 입력', + enter_password_description: '계속하려면 계정 비밀번호를 입력하세요.', + enter_verification_code_description: '{{identifier}}(으)로 전송된 인증 코드를 입력하세요.', + subject_proof_description: + '계속하려면 내 계정에 연결된 계정을 통해 본인임을 확인하세요. 그런 다음 인증 방법을 설정할 수 있습니다.', + no_method_available: '사용 가능한 인증 방법 없음', + no_method_available_description: + '이 계정에는 이 확인을 완료할 수 있는 방법이 없습니다. 도움이 필요하면 관리자에게 문의하세요.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/pl-pl/index.ts b/packages/phrases-experience/src/locales/pl-pl/index.ts index 68d3df90e0a6..176d91dcc99d 100644 --- a/packages/phrases-experience/src/locales/pl-pl/index.ts +++ b/packages/phrases-experience/src/locales/pl-pl/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const pl_pl = { @@ -29,6 +30,7 @@ const pl_pl = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/pl-pl/step-up.ts b/packages/phrases-experience/src/locales/pl-pl/step-up.ts new file mode 100644 index 000000000000..a3175cc46bb0 --- /dev/null +++ b/packages/phrases-experience/src/locales/pl-pl/step-up.ts @@ -0,0 +1,16 @@ +const step_up = { + verify_your_identity: 'Zweryfikuj swoją tożsamość', + choose_method_description: + 'Aby kontynuować, potwierdź swoją tożsamość za pomocą jednej z poniższych metod.', + password: 'Hasło', + password_description: 'Wprowadź hasło do swojego konta', + enter_password_description: 'Wprowadź hasło do swojego konta, aby kontynuować.', + enter_verification_code_description: 'Wprowadź kod weryfikacyjny wysłany na {{identifier}}.', + subject_proof_description: + 'Aby kontynuować, potwierdź swoją tożsamość za pomocą konta powiązanego z Twoim. Następnie możesz skonfigurować metodę weryfikacji.', + no_method_available: 'Brak dostępnej metody weryfikacji', + no_method_available_description: + 'Twoje konto nie ma metody, która mogłaby ukończyć tę weryfikację. Skontaktuj się z administratorem, aby uzyskać pomoc.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/pt-br/index.ts b/packages/phrases-experience/src/locales/pt-br/index.ts index 22cee9f92dc4..2dea08221541 100644 --- a/packages/phrases-experience/src/locales/pt-br/index.ts +++ b/packages/phrases-experience/src/locales/pt-br/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const pt_br = { @@ -29,6 +30,7 @@ const pt_br = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/pt-br/step-up.ts b/packages/phrases-experience/src/locales/pt-br/step-up.ts new file mode 100644 index 000000000000..df48350e3ac7 --- /dev/null +++ b/packages/phrases-experience/src/locales/pt-br/step-up.ts @@ -0,0 +1,16 @@ +const step_up = { + verify_your_identity: 'Verifique sua identidade', + choose_method_description: 'Para continuar, confirme que é você com um dos métodos a seguir.', + password: 'Senha', + password_description: 'Digite a senha da sua conta', + enter_password_description: 'Digite a senha da sua conta para continuar.', + enter_verification_code_description: + 'Digite o código de verificação enviado para {{identifier}}.', + subject_proof_description: + 'Para continuar, confirme que é você por meio de uma conta vinculada à sua. Depois, você poderá configurar um método de verificação.', + no_method_available: 'Nenhum método de verificação disponível', + no_method_available_description: + 'Sua conta não possui nenhum método capaz de concluir esta verificação. Entre em contato com o administrador para obter ajuda.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/pt-pt/index.ts b/packages/phrases-experience/src/locales/pt-pt/index.ts index 5aaadd843215..7ca0c19634e8 100644 --- a/packages/phrases-experience/src/locales/pt-pt/index.ts +++ b/packages/phrases-experience/src/locales/pt-pt/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const pt_pt = { @@ -29,6 +30,7 @@ const pt_pt = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/pt-pt/step-up.ts b/packages/phrases-experience/src/locales/pt-pt/step-up.ts new file mode 100644 index 000000000000..473aa56716a6 --- /dev/null +++ b/packages/phrases-experience/src/locales/pt-pt/step-up.ts @@ -0,0 +1,16 @@ +const step_up = { + verify_your_identity: 'Verifique a sua identidade', + choose_method_description: 'Para continuar, confirme que é você com um dos seguintes métodos.', + password: 'Palavra-passe', + password_description: 'Introduza a palavra-passe da sua conta', + enter_password_description: 'Introduza a palavra-passe da sua conta para continuar.', + enter_verification_code_description: + 'Introduza o código de verificação enviado para {{identifier}}.', + subject_proof_description: + 'Para continuar, confirme que é você através de uma conta associada à sua. Depois, poderá configurar um método de verificação.', + no_method_available: 'Nenhum método de verificação disponível', + no_method_available_description: + 'A sua conta não tem nenhum método que possa concluir esta verificação. Contacte o seu administrador para obter assistência.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/ru/index.ts b/packages/phrases-experience/src/locales/ru/index.ts index 054ea0222800..f4488f2a9c62 100644 --- a/packages/phrases-experience/src/locales/ru/index.ts +++ b/packages/phrases-experience/src/locales/ru/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const ru = { @@ -29,6 +30,7 @@ const ru = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/ru/step-up.ts b/packages/phrases-experience/src/locales/ru/step-up.ts new file mode 100644 index 000000000000..c210b64f75ab --- /dev/null +++ b/packages/phrases-experience/src/locales/ru/step-up.ts @@ -0,0 +1,16 @@ +const step_up = { + verify_your_identity: 'Подтвердите свою личность', + choose_method_description: + 'Чтобы продолжить, подтвердите, что это вы, одним из следующих способов.', + password: 'Пароль', + password_description: 'Введите пароль от вашей учетной записи', + enter_password_description: 'Введите пароль от вашей учетной записи, чтобы продолжить.', + enter_verification_code_description: 'Введите код подтверждения, отправленный на {{identifier}}.', + subject_proof_description: + 'Чтобы продолжить, подтвердите, что это вы, через связанную с вашей учетную запись. После этого вы сможете настроить способ проверки.', + no_method_available: 'Нет доступного способа проверки', + no_method_available_description: + 'В вашей учетной записи нет способа, с помощью которого можно пройти эту проверку. Пожалуйста, обратитесь к администратору за помощью.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/th/index.ts b/packages/phrases-experience/src/locales/th/index.ts index 4769780c8e4c..b8d83c036c53 100644 --- a/packages/phrases-experience/src/locales/th/index.ts +++ b/packages/phrases-experience/src/locales/th/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const th = { @@ -29,6 +30,7 @@ const th = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/th/step-up.ts b/packages/phrases-experience/src/locales/th/step-up.ts new file mode 100644 index 000000000000..683c3e102bae --- /dev/null +++ b/packages/phrases-experience/src/locales/th/step-up.ts @@ -0,0 +1,15 @@ +const step_up = { + verify_your_identity: 'ยืนยันตัวตนของคุณ', + choose_method_description: 'เพื่อดำเนินการต่อ กรุณายืนยันว่าเป็นคุณด้วยวิธีใดวิธีหนึ่งต่อไปนี้', + password: 'รหัสผ่าน', + password_description: 'กรอกรหัสผ่านบัญชีของคุณ', + enter_password_description: 'กรอกรหัสผ่านบัญชีของคุณเพื่อดำเนินการต่อ', + enter_verification_code_description: 'กรอกรหัสยืนยันที่ส่งไปยัง {{identifier}}', + subject_proof_description: + 'เพื่อดำเนินการต่อ กรุณายืนยันว่าเป็นคุณผ่านบัญชีที่เชื่อมโยงกับบัญชีของคุณ จากนั้นคุณจะสามารถตั้งค่าวิธีการยืนยันได้', + no_method_available: 'ไม่มีวิธีการยืนยันที่ใช้ได้', + no_method_available_description: + 'บัญชีของคุณไม่มีวิธีการที่สามารถทำการยืนยันนี้ให้เสร็จสิ้นได้ กรุณาติดต่อผู้ดูแลระบบเพื่อขอความช่วยเหลือ', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/tr-tr/index.ts b/packages/phrases-experience/src/locales/tr-tr/index.ts index d93668b5ec32..0e300eb70a64 100644 --- a/packages/phrases-experience/src/locales/tr-tr/index.ts +++ b/packages/phrases-experience/src/locales/tr-tr/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const tr_tr = { @@ -29,6 +30,7 @@ const tr_tr = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/tr-tr/step-up.ts b/packages/phrases-experience/src/locales/tr-tr/step-up.ts new file mode 100644 index 000000000000..55e11997f502 --- /dev/null +++ b/packages/phrases-experience/src/locales/tr-tr/step-up.ts @@ -0,0 +1,16 @@ +const step_up = { + verify_your_identity: 'Kimliğinizi doğrulayın', + choose_method_description: + 'Devam etmek için aşağıdaki yöntemlerden biriyle siz olduğunuzu doğrulayın.', + password: 'Şifre', + password_description: 'Hesap şifrenizi girin', + enter_password_description: 'Devam etmek için hesap şifrenizi girin.', + enter_verification_code_description: '{{identifier}} adresine gönderilen doğrulama kodunu girin.', + subject_proof_description: + 'Devam etmek için hesabınıza bağlı bir hesap üzerinden siz olduğunuzu doğrulayın. Ardından bir doğrulama yöntemi ayarlayabilirsiniz.', + no_method_available: 'Kullanılabilir doğrulama yöntemi yok', + no_method_available_description: + 'Hesabınızda bu doğrulamayı tamamlayabilecek bir yöntem yok. Yardım için lütfen yöneticinizle iletişime geçin.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/uk-ua/index.ts b/packages/phrases-experience/src/locales/uk-ua/index.ts index 1a6ae2d3555f..db2212b64a76 100644 --- a/packages/phrases-experience/src/locales/uk-ua/index.ts +++ b/packages/phrases-experience/src/locales/uk-ua/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const uk_ua = { @@ -29,6 +30,7 @@ const uk_ua = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/uk-ua/step-up.ts b/packages/phrases-experience/src/locales/uk-ua/step-up.ts new file mode 100644 index 000000000000..a93387980801 --- /dev/null +++ b/packages/phrases-experience/src/locales/uk-ua/step-up.ts @@ -0,0 +1,15 @@ +const step_up = { + verify_your_identity: 'Підтвердьте свою особу', + choose_method_description: 'Щоб продовжити, підтвердьте, що це ви, одним із наведених способів.', + password: 'Пароль', + password_description: 'Введіть пароль свого облікового запису', + enter_password_description: 'Введіть пароль свого облікового запису, щоб продовжити.', + enter_verification_code_description: 'Введіть код підтвердження, надісланий на {{identifier}}.', + subject_proof_description: + 'Щоб продовжити, підтвердьте, що це ви, через обліковий запис, пов’язаний із вашим. Після цього ви зможете налаштувати спосіб перевірки.', + no_method_available: 'Немає доступного способу перевірки', + no_method_available_description: + 'У вашому обліковому записі немає способу, яким можна пройти цю перевірку. Будь ласка, зверніться до адміністратора по допомогу.', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/zh-cn/index.ts b/packages/phrases-experience/src/locales/zh-cn/index.ts index 4d47b96cd056..af85939e4a22 100644 --- a/packages/phrases-experience/src/locales/zh-cn/index.ts +++ b/packages/phrases-experience/src/locales/zh-cn/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const zh_cn = { @@ -29,6 +30,7 @@ const zh_cn = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/zh-cn/step-up.ts b/packages/phrases-experience/src/locales/zh-cn/step-up.ts new file mode 100644 index 000000000000..bbf6b6b79906 --- /dev/null +++ b/packages/phrases-experience/src/locales/zh-cn/step-up.ts @@ -0,0 +1,14 @@ +const step_up = { + verify_your_identity: '验证你的身份', + choose_method_description: '请通过以下任一方式确认是你本人,以继续操作。', + password: '密码', + password_description: '输入你的账户密码', + enter_password_description: '输入你的账户密码以继续。', + enter_verification_code_description: '输入发送至 {{identifier}} 的验证码。', + subject_proof_description: + '请通过与你账户关联的账户确认是你本人,以继续操作。之后你可以设置一种验证方式。', + no_method_available: '没有可用的验证方式', + no_method_available_description: '你的账户没有可完成此验证的方式。请联系管理员获取帮助。', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/zh-hk/index.ts b/packages/phrases-experience/src/locales/zh-hk/index.ts index b78b468ca37b..3226f97cfd66 100644 --- a/packages/phrases-experience/src/locales/zh-hk/index.ts +++ b/packages/phrases-experience/src/locales/zh-hk/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const zh_hk = { @@ -29,6 +30,7 @@ const zh_hk = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/zh-hk/step-up.ts b/packages/phrases-experience/src/locales/zh-hk/step-up.ts new file mode 100644 index 000000000000..ed826723a930 --- /dev/null +++ b/packages/phrases-experience/src/locales/zh-hk/step-up.ts @@ -0,0 +1,14 @@ +const step_up = { + verify_your_identity: '驗證你的身份', + choose_method_description: '請使用以下其中一種方式確認是你本人,以繼續操作。', + password: '密碼', + password_description: '輸入你的帳戶密碼', + enter_password_description: '輸入你的帳戶密碼以繼續。', + enter_verification_code_description: '輸入發送至 {{identifier}} 的驗證碼。', + subject_proof_description: + '請透過與你帳戶關聯的另一個帳戶確認是你本人,以繼續操作。之後你可以設定驗證方式。', + no_method_available: '沒有可用的驗證方式', + no_method_available_description: '你的帳戶沒有可完成此驗證的方式。請聯絡你的管理員以尋求協助。', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases-experience/src/locales/zh-tw/index.ts b/packages/phrases-experience/src/locales/zh-tw/index.ts index 872748eb6dda..e52284a1499f 100644 --- a/packages/phrases-experience/src/locales/zh-tw/index.ts +++ b/packages/phrases-experience/src/locales/zh-tw/index.ts @@ -13,6 +13,7 @@ import mfa from './mfa.js'; import passkey_sign_in from './passkey-sign-in.js'; import profile from './profile.js'; import secondary from './secondary.js'; +import step_up from './step-up.js'; import user_scopes from './user-scopes.js'; const zh_tw = { @@ -29,6 +30,7 @@ const zh_tw = { profile, account_center, passkey_sign_in, + step_up, }, } satisfies DeepPartial; diff --git a/packages/phrases-experience/src/locales/zh-tw/step-up.ts b/packages/phrases-experience/src/locales/zh-tw/step-up.ts new file mode 100644 index 000000000000..2da3188654f8 --- /dev/null +++ b/packages/phrases-experience/src/locales/zh-tw/step-up.ts @@ -0,0 +1,14 @@ +const step_up = { + verify_your_identity: '驗證您的身分', + choose_method_description: '若要繼續,請使用以下任一方式確認是您本人。', + password: '密碼', + password_description: '輸入您的帳戶密碼', + enter_password_description: '輸入您的帳戶密碼以繼續。', + enter_verification_code_description: '輸入發送至 {{identifier}} 的驗證碼。', + subject_proof_description: + '若要繼續,請透過與您帳戶連結的另一個帳戶確認是您本人。之後即可設定驗證方式。', + no_method_available: '沒有可用的驗證方式', + no_method_available_description: '您的帳戶沒有可完成此驗證的方式。請聯絡您的管理員以取得協助。', +}; + +export default Object.freeze(step_up); diff --git a/packages/phrases/src/locales/ar/errors/session.ts b/packages/phrases/src/locales/ar/errors/session.ts index caa024041512..0c93792fcbc2 100644 --- a/packages/phrases/src/locales/ar/errors/session.ts +++ b/packages/phrases/src/locales/ar/errors/session.ts @@ -47,6 +47,8 @@ const session = { forbidden_route: 'هذا المسار غير مسموح به أثناء المصادقة المعززة.', forbidden_identifier: 'لا يُسمح بتضمين المعرّف أثناء المصادقة المعززة. أعد المحاولة بدون حقل المعرّف.', + acr_not_satisfied: 'التحقق المكتمل لا يفي بسياق المصادقة المطلوب. يرجى التحقق بطريقة أخرى.', + require_verification: 'يلزم التحقق بإحدى طرقك الحالية للوصول إلى سياق المصادقة المطلوب.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/de/errors/session.ts b/packages/phrases/src/locales/de/errors/session.ts index ed55bdbf739c..4bf01c6a3dbe 100644 --- a/packages/phrases/src/locales/de/errors/session.ts +++ b/packages/phrases/src/locales/de/errors/session.ts @@ -58,6 +58,10 @@ const session = { forbidden_route: 'Diese Route ist während der Step-up-Authentifizierung nicht zulässig.', forbidden_identifier: 'Ein Identifier ist während der Step-up-Authentifizierung nicht zulässig. Sende die Anfrage ohne das Identifier-Feld erneut.', + acr_not_satisfied: + 'Die abgeschlossene Verifizierung erfüllt nicht den angeforderten Authentifizierungskontext. Bitte verifiziere eine andere Methode.', + require_verification: + 'Um den angeforderten Authentifizierungskontext zu erreichen, ist eine Verifizierung mit einer deiner vorhandenen Methoden erforderlich.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/en/errors/session.ts b/packages/phrases/src/locales/en/errors/session.ts index ebe92b5596a8..c28383e77d96 100644 --- a/packages/phrases/src/locales/en/errors/session.ts +++ b/packages/phrases/src/locales/en/errors/session.ts @@ -53,6 +53,10 @@ const session = { forbidden_route: 'This route is not allowed during step-up authentication.', forbidden_identifier: 'An identifier is not allowed during step-up authentication. Retry without the identifier field.', + acr_not_satisfied: + 'The completed verification does not satisfy the requested authentication context. Please verify another method.', + require_verification: + 'Verification with one of your existing methods is required to reach the requested authentication context.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/es/errors/session.ts b/packages/phrases/src/locales/es/errors/session.ts index 437383082401..c78ad82d3c9a 100644 --- a/packages/phrases/src/locales/es/errors/session.ts +++ b/packages/phrases/src/locales/es/errors/session.ts @@ -59,6 +59,10 @@ const session = { forbidden_route: 'Esta ruta no está permitida durante la autenticación reforzada.', forbidden_identifier: 'No se permite un identificador durante la autenticación reforzada. Vuelve a intentarlo sin el campo de identificador.', + acr_not_satisfied: + 'La verificación completada no cumple con el contexto de autenticación solicitado. Verifica otro método.', + require_verification: + 'Se requiere la verificación con uno de tus métodos existentes para alcanzar el contexto de autenticación solicitado.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/fa-ir/errors/session.ts b/packages/phrases/src/locales/fa-ir/errors/session.ts index 95a63f841242..05c0d7ce2e13 100644 --- a/packages/phrases/src/locales/fa-ir/errors/session.ts +++ b/packages/phrases/src/locales/fa-ir/errors/session.ts @@ -50,6 +50,10 @@ const session = { forbidden_route: 'این مسیر در حین احراز هویت تقویتی مجاز نیست.', forbidden_identifier: 'شناسه در حین احراز هویت تقویتی مجاز نیست. بدون فیلد شناسه دوباره تلاش کنید.', + acr_not_satisfied: + 'تأیید انجام‌شده زمینه احراز هویت درخواستی را برآورده نمی‌کند. لطفاً روش دیگری را تأیید کنید.', + require_verification: + 'برای رسیدن به زمینه احراز هویت درخواستی، تأیید با یکی از روش‌های موجود شما لازم است.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/fr/errors/session.ts b/packages/phrases/src/locales/fr/errors/session.ts index 13a5ea66f5aa..7f1bc6abfd5e 100644 --- a/packages/phrases/src/locales/fr/errors/session.ts +++ b/packages/phrases/src/locales/fr/errors/session.ts @@ -60,6 +60,10 @@ const session = { forbidden_route: "Cette route n'est pas autorisée pendant l'authentification renforcée.", forbidden_identifier: "Un identifiant n'est pas autorisé pendant l'authentification renforcée. Réessayez sans le champ identifiant.", + acr_not_satisfied: + "La vérification effectuée ne satisfait pas le contexte d'authentification demandé. Veuillez vérifier une autre méthode.", + require_verification: + "Une vérification avec l'une de vos méthodes existantes est requise pour atteindre le contexte d'authentification demandé.", }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/it/errors/session.ts b/packages/phrases/src/locales/it/errors/session.ts index ab83d8899222..caddd9a5b34e 100644 --- a/packages/phrases/src/locales/it/errors/session.ts +++ b/packages/phrases/src/locales/it/errors/session.ts @@ -57,6 +57,10 @@ const session = { forbidden_route: "Questa route non è consentita durante l'autenticazione step-up.", forbidden_identifier: "Un identificatore non è consentito durante l'autenticazione step-up. Riprova senza il campo identificatore.", + acr_not_satisfied: + 'La verifica completata non soddisfa il contesto di autenticazione richiesto. Verifica un altro metodo.', + require_verification: + 'Per raggiungere il contesto di autenticazione richiesto è necessaria la verifica con uno dei tuoi metodi esistenti.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/ja/errors/session.ts b/packages/phrases/src/locales/ja/errors/session.ts index 5a62412aca18..b58437de35b0 100644 --- a/packages/phrases/src/locales/ja/errors/session.ts +++ b/packages/phrases/src/locales/ja/errors/session.ts @@ -55,6 +55,10 @@ const session = { forbidden_route: 'このルートはステップアップ認証中は許可されていません。', forbidden_identifier: 'ステップアップ認証中は識別子を指定できません。識別子フィールドを省略して再試行してください。', + acr_not_satisfied: + '完了した認証は要求された認証コンテキストを満たしていません。別の方法で認証してください。', + require_verification: + '要求された認証コンテキストに到達するには、既存のいずれかの方法による認証が必要です。', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/ko/errors/session.ts b/packages/phrases/src/locales/ko/errors/session.ts index 73e75620a2a5..64d0d2050f32 100644 --- a/packages/phrases/src/locales/ko/errors/session.ts +++ b/packages/phrases/src/locales/ko/errors/session.ts @@ -50,6 +50,10 @@ const session = { forbidden_route: '이 경로는 스텝업 인증 중에 허용되지 않습니다.', forbidden_identifier: '스텝업 인증 중에는 식별자를 사용할 수 없습니다. 식별자 필드를 제거하고 다시 시도해 주세요.', + acr_not_satisfied: + '완료된 인증이 요청된 인증 컨텍스트를 충족하지 않습니다. 다른 방법으로 인증해 주세요.', + require_verification: + '요청된 인증 컨텍스트에 도달하려면 기존 인증 방법 중 하나로 인증해야 합니다.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/pl-pl/errors/session.ts b/packages/phrases/src/locales/pl-pl/errors/session.ts index d6af0e7da74e..fd1aad546d25 100644 --- a/packages/phrases/src/locales/pl-pl/errors/session.ts +++ b/packages/phrases/src/locales/pl-pl/errors/session.ts @@ -56,6 +56,10 @@ const session = { forbidden_route: 'Ta trasa nie jest dozwolona podczas uwierzytelniania step-up.', forbidden_identifier: 'Identyfikator nie jest dozwolony podczas uwierzytelniania step-up. Spróbuj ponownie bez pola identyfikatora.', + acr_not_satisfied: + 'Ukończona weryfikacja nie spełnia wymaganego kontekstu uwierzytelniania. Zweryfikuj inną metodę.', + require_verification: + 'Aby osiągnąć wymagany kontekst uwierzytelniania, wymagana jest weryfikacja za pomocą jednej z Twoich istniejących metod.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/pt-br/errors/session.ts b/packages/phrases/src/locales/pt-br/errors/session.ts index a82ac8804a3a..b03c3a439750 100644 --- a/packages/phrases/src/locales/pt-br/errors/session.ts +++ b/packages/phrases/src/locales/pt-br/errors/session.ts @@ -57,6 +57,10 @@ const session = { forbidden_route: 'Esta rota não é permitida durante a autenticação step-up.', forbidden_identifier: 'Um identificador não é permitido durante a autenticação step-up. Tente novamente sem o campo identificador.', + acr_not_satisfied: + 'A verificação concluída não atende ao contexto de autenticação solicitado. Verifique outro método.', + require_verification: + 'É necessário verificar um dos seus métodos existentes para atingir o contexto de autenticação solicitado.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/pt-pt/errors/session.ts b/packages/phrases/src/locales/pt-pt/errors/session.ts index c5a53f35c8e5..cbdf46a540c1 100644 --- a/packages/phrases/src/locales/pt-pt/errors/session.ts +++ b/packages/phrases/src/locales/pt-pt/errors/session.ts @@ -59,6 +59,10 @@ const session = { forbidden_route: 'Esta rota não é permitida durante a autenticação step-up.', forbidden_identifier: 'Não é permitido um identificador durante a autenticação step-up. Tente novamente sem o campo identificador.', + acr_not_satisfied: + 'A verificação concluída não satisfaz o contexto de autenticação solicitado. Verifique outro método.', + require_verification: + 'É necessária a verificação com um dos seus métodos existentes para atingir o contexto de autenticação solicitado.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/ru/errors/session.ts b/packages/phrases/src/locales/ru/errors/session.ts index 9c5b1d4fb01c..e1e16e17b6b7 100644 --- a/packages/phrases/src/locales/ru/errors/session.ts +++ b/packages/phrases/src/locales/ru/errors/session.ts @@ -56,6 +56,10 @@ const session = { forbidden_route: 'Этот маршрут недоступен во время повышения уровня аутентификации.', forbidden_identifier: 'Указывать идентификатор при повышении уровня аутентификации нельзя. Повторите попытку без поля идентификатора.', + acr_not_satisfied: + 'Завершенная проверка не соответствует запрошенному контексту аутентификации. Проверьте другой способ.', + require_verification: + 'Чтобы достичь запрошенного контекста аутентификации, необходимо пройти проверку одним из ваших существующих способов.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/th/errors/session.ts b/packages/phrases/src/locales/th/errors/session.ts index 76cabf8afd17..af841e9766ad 100644 --- a/packages/phrases/src/locales/th/errors/session.ts +++ b/packages/phrases/src/locales/th/errors/session.ts @@ -49,6 +49,10 @@ const session = { forbidden_route: 'เส้นทางนี้ไม่ได้รับอนุญาตในระหว่างการยืนยันตัวตนแบบยกระดับ', forbidden_identifier: 'ไม่อนุญาตให้ระบุ identifier ในระหว่างการยืนยันตัวตนแบบยกระดับ โปรดลองอีกครั้งโดยไม่ส่งฟิลด์นี้', + acr_not_satisfied: + 'การยืนยันที่เสร็จสิ้นไม่เป็นไปตามบริบทการยืนยันตัวตนที่ร้องขอ กรุณายืนยันด้วยวิธีอื่น', + require_verification: + 'จำเป็นต้องยืนยันด้วยวิธีที่มีอยู่ของคุณวิธีใดวิธีหนึ่งเพื่อให้ถึงบริบทการยืนยันตัวตนที่ร้องขอ', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/tr-tr/errors/session.ts b/packages/phrases/src/locales/tr-tr/errors/session.ts index 7258dc2a0aeb..a3b49f9577d3 100644 --- a/packages/phrases/src/locales/tr-tr/errors/session.ts +++ b/packages/phrases/src/locales/tr-tr/errors/session.ts @@ -55,6 +55,10 @@ const session = { forbidden_route: 'Bu yol, yükseltilmiş kimlik doğrulama sırasında izin verilmez.', forbidden_identifier: 'Yükseltilmiş kimlik doğrulama sırasında tanımlayıcı kullanılamaz. Tanımlayıcı alanı olmadan tekrar deneyin.', + acr_not_satisfied: + 'Tamamlanan doğrulama, istenen kimlik doğrulama bağlamını karşılamıyor. Lütfen başka bir yöntemi doğrulayın.', + require_verification: + 'İstenen kimlik doğrulama bağlamına ulaşmak için mevcut yöntemlerinizden biriyle doğrulama yapmanız gerekir.', }, passkey_sign_in: { pending_info_not_found: diff --git a/packages/phrases/src/locales/zh-cn/errors/session.ts b/packages/phrases/src/locales/zh-cn/errors/session.ts index ea8027dc54de..4d9b80e53917 100644 --- a/packages/phrases/src/locales/zh-cn/errors/session.ts +++ b/packages/phrases/src/locales/zh-cn/errors/session.ts @@ -43,6 +43,8 @@ const session = { subject_not_found: '未找到用于升级认证的已认证会话。请重新登录。', forbidden_route: '升级认证期间不允许访问此路由。', forbidden_identifier: '升级认证期间不允许提供标识符。请移除 identifier 字段后重试。', + acr_not_satisfied: '已完成的验证不满足请求的认证上下文,请验证其他方式。', + require_verification: '需要使用你现有的任一验证方式完成验证,才能达到请求的认证上下文。', }, passkey_sign_in: { pending_info_not_found: '未找到待处理的 Passkey 登录信息。请重新发起登录流程。', diff --git a/packages/phrases/src/locales/zh-hk/errors/session.ts b/packages/phrases/src/locales/zh-hk/errors/session.ts index 076fba898717..3bd743cba887 100644 --- a/packages/phrases/src/locales/zh-hk/errors/session.ts +++ b/packages/phrases/src/locales/zh-hk/errors/session.ts @@ -43,6 +43,8 @@ const session = { subject_not_found: '找不到用於升級認證的已認證會話。請重新登入。', forbidden_route: '升級認證期間不允許存取此路由。', forbidden_identifier: '升級認證期間不允許提供標識符。請移除 identifier 欄位後重試。', + acr_not_satisfied: '已完成的驗證不符合要求的認證情境,請驗證其他方式。', + require_verification: '你需要使用其中一種現有的驗證方式進行驗證,才能達到要求的認證情境。', }, passkey_sign_in: { pending_info_not_found: '未找到待處理的 Passkey 登入資訊。請重新啟動登入流程。', diff --git a/packages/phrases/src/locales/zh-tw/errors/session.ts b/packages/phrases/src/locales/zh-tw/errors/session.ts index 540168e2711b..bd51cc67b70e 100644 --- a/packages/phrases/src/locales/zh-tw/errors/session.ts +++ b/packages/phrases/src/locales/zh-tw/errors/session.ts @@ -43,6 +43,8 @@ const session = { subject_not_found: '找不到用於升級驗證的已驗證工作階段。請重新登入。', forbidden_route: '升級驗證期間不允許存取此路由。', forbidden_identifier: '升級驗證期間不允許提供識別碼。請移除 identifier 欄位後重試。', + acr_not_satisfied: '已完成的驗證不符合要求的驗證情境,請驗證其他方式。', + require_verification: '必須使用您現有的任一驗證方式進行驗證,才能達到要求的驗證情境。', }, passkey_sign_in: { pending_info_not_found: '未找到待處理的 Passkey 登入資訊。請重新啟動登入流程。', diff --git a/packages/schemas/src/types/log/interaction.ts b/packages/schemas/src/types/log/interaction.ts index 941f163b7ae7..1b5b82501f58 100644 --- a/packages/schemas/src/types/log/interaction.ts +++ b/packages/schemas/src/types/log/interaction.ts @@ -17,6 +17,11 @@ export enum Field { Verification = 'Verification', Captcha = 'Captcha', SignInPasskey = 'SignInPasskey', + /** + * A pure step-up submission: an authenticated session re-proved its subject to reach a requested + * authentication context class. Not a sign-in, so it never shares `SignIn.Submit`. + */ + StepUp = 'StepUp', } /** Method to verify the identifier */ @@ -103,6 +108,14 @@ export type DeprecatedInteractionLogKey = * - Indicates an identifier method is being created or submitted to an interaction. * - When {@link Method} is `VerificationCode`, {@link Action} can be `Create` (generate and send a code) or `Submit` (verify and submit to the identifiers); * - Otherwise, {@link Action} is fixed to `Submit` (other methods can be verified on submitting). + * + * ```ts + * `Interaction.${InteractionEvent}.${Field.StepUp}.${Action.Submit}` + * ``` + * + * - Indicates a pure step-up interaction is submitted: an authenticated session re-proved its + * subject to reach the requested authentication context class. Its payload records the requested + * and achieved classes and the factor families that were proved, never a credential. */ export type LogKey = | `${Prefix}.${Action.Create | Action.End}` @@ -113,6 +126,7 @@ export type LogKey = | `${Prefix}.${InteractionEvent}.${Field.SignInPasskey}.${Action.Submit}` | `${Prefix}.${InteractionEvent}.${Field.Verification}.${VerificationType}.${Action}` | `${Prefix}.${InteractionEvent}.${Field.Identifier}.${Action.Submit}` + | `${Prefix}.${InteractionEvent}.${Field.StepUp}.${Action.Submit}` // IdpInitiatedSingleSignOn log, used upon receiving a SAML request from the IdP | `${Prefix}.${InteractionEvent.SignIn}.${Field.Verification}.IdpInitiatedSso.${Action.Create}` | DeprecatedInteractionLogKey;