@contember/engine-tenant-api 2.2.0-alpha.5 → 2.2.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -0
- package/dist/development/index.cjs +2 -0
- package/dist/development/index.cjs.map +1 -1
- package/dist/development/index.js +2 -0
- package/dist/development/index.js.map +1 -1
- package/dist/development/src/migrations/2026-09-04-120000-idp-disable-local-authentication.cjs +18 -0
- package/dist/development/src/migrations/2026-09-04-120000-idp-disable-local-authentication.cjs.map +1 -0
- package/dist/development/src/migrations/2026-09-04-120000-idp-disable-local-authentication.js +19 -0
- package/dist/development/src/migrations/2026-09-04-120000-idp-disable-local-authentication.js.map +1 -0
- package/dist/development/src/migrations/runner.cjs +3 -1
- package/dist/development/src/migrations/runner.cjs.map +1 -1
- package/dist/development/src/migrations/runner.js +3 -1
- package/dist/development/src/migrations/runner.js.map +1 -1
- package/dist/development/src/migrations/snapshot.cjs +2 -1
- package/dist/development/src/migrations/snapshot.cjs.map +1 -1
- package/dist/development/src/migrations/snapshot.js +2 -1
- package/dist/development/src/migrations/snapshot.js.map +1 -1
- package/dist/development/src/model/commands/idp/ClaimIdpRevalidationCommand.cjs +3 -7
- package/dist/development/src/model/commands/idp/ClaimIdpRevalidationCommand.cjs.map +1 -1
- package/dist/development/src/model/commands/idp/ClaimIdpRevalidationCommand.js +3 -7
- package/dist/development/src/model/commands/idp/ClaimIdpRevalidationCommand.js.map +1 -1
- package/dist/development/src/model/commands/idp/CreateIdpCommand.cjs +8 -2
- package/dist/development/src/model/commands/idp/CreateIdpCommand.cjs.map +1 -1
- package/dist/development/src/model/commands/idp/CreateIdpCommand.js +8 -2
- package/dist/development/src/model/commands/idp/CreateIdpCommand.js.map +1 -1
- package/dist/development/src/model/commands/idp/UpdateIdpCommand.cjs +6 -2
- package/dist/development/src/model/commands/idp/UpdateIdpCommand.cjs.map +1 -1
- package/dist/development/src/model/commands/idp/UpdateIdpCommand.js +6 -2
- package/dist/development/src/model/commands/idp/UpdateIdpCommand.js.map +1 -1
- package/dist/development/src/model/queries/idp/LocalAuthDisablingIdpsQuery.cjs +21 -0
- package/dist/development/src/model/queries/idp/LocalAuthDisablingIdpsQuery.cjs.map +1 -0
- package/dist/development/src/model/queries/idp/LocalAuthDisablingIdpsQuery.js +21 -0
- package/dist/development/src/model/queries/idp/LocalAuthDisablingIdpsQuery.js.map +1 -0
- package/dist/development/src/model/queries/idp/PersonIdentityProvidersQuery.cjs +1 -1
- package/dist/development/src/model/queries/idp/PersonIdentityProvidersQuery.cjs.map +1 -1
- package/dist/development/src/model/queries/idp/PersonIdentityProvidersQuery.js +1 -1
- package/dist/development/src/model/queries/idp/PersonIdentityProvidersQuery.js.map +1 -1
- package/dist/development/src/model/queries/idp/helpers.cjs +3 -3
- package/dist/development/src/model/queries/idp/helpers.cjs.map +1 -1
- package/dist/development/src/model/queries/idp/helpers.js +3 -3
- package/dist/development/src/model/queries/idp/helpers.js.map +1 -1
- package/dist/development/src/model/service/PasswordResetManager.cjs +5 -0
- package/dist/development/src/model/service/PasswordResetManager.cjs.map +1 -1
- package/dist/development/src/model/service/PasswordResetManager.js +5 -0
- package/dist/development/src/model/service/PasswordResetManager.js.map +1 -1
- package/dist/development/src/model/service/PasswordlessSignInManager.cjs +19 -0
- package/dist/development/src/model/service/PasswordlessSignInManager.cjs.map +1 -1
- package/dist/development/src/model/service/PasswordlessSignInManager.js +19 -0
- package/dist/development/src/model/service/PasswordlessSignInManager.js.map +1 -1
- package/dist/development/src/model/service/SignInManager.cjs +5 -0
- package/dist/development/src/model/service/SignInManager.cjs.map +1 -1
- package/dist/development/src/model/service/SignInManager.js +5 -0
- package/dist/development/src/model/service/SignInManager.js.map +1 -1
- package/dist/development/src/model/service/idp/PersonIdentityProviderManager.cjs +6 -0
- package/dist/development/src/model/service/idp/PersonIdentityProviderManager.cjs.map +1 -1
- package/dist/development/src/model/service/idp/PersonIdentityProviderManager.js +6 -0
- package/dist/development/src/model/service/idp/PersonIdentityProviderManager.js.map +1 -1
- package/dist/development/src/resolvers/mutation/idp/AddIDPMutationResolver.cjs +2 -1
- package/dist/development/src/resolvers/mutation/idp/AddIDPMutationResolver.cjs.map +1 -1
- package/dist/development/src/resolvers/mutation/idp/AddIDPMutationResolver.js +2 -1
- package/dist/development/src/resolvers/mutation/idp/AddIDPMutationResolver.js.map +1 -1
- package/dist/development/src/resolvers/mutation/idp/UpdateIDPMutationResolver.cjs +2 -1
- package/dist/development/src/resolvers/mutation/idp/UpdateIDPMutationResolver.cjs.map +1 -1
- package/dist/development/src/resolvers/mutation/idp/UpdateIDPMutationResolver.js +2 -1
- package/dist/development/src/resolvers/mutation/idp/UpdateIDPMutationResolver.js.map +1 -1
- package/dist/development/src/resolvers/mutation/idp/audit.cjs +1 -0
- package/dist/development/src/resolvers/mutation/idp/audit.cjs.map +1 -1
- package/dist/development/src/resolvers/mutation/idp/audit.js +1 -0
- package/dist/development/src/resolvers/mutation/idp/audit.js.map +1 -1
- package/dist/development/src/resolvers/mutation/person/PasswordlessMutationResolver.cjs +4 -0
- package/dist/development/src/resolvers/mutation/person/PasswordlessMutationResolver.cjs.map +1 -1
- package/dist/development/src/resolvers/mutation/person/PasswordlessMutationResolver.js +4 -0
- package/dist/development/src/resolvers/mutation/person/PasswordlessMutationResolver.js.map +1 -1
- package/dist/development/src/resolvers/mutation/person/SignInMutationResolver.cjs +2 -2
- package/dist/development/src/resolvers/mutation/person/SignInMutationResolver.cjs.map +1 -1
- package/dist/development/src/resolvers/mutation/person/SignInMutationResolver.js +2 -2
- package/dist/development/src/resolvers/mutation/person/SignInMutationResolver.js.map +1 -1
- package/dist/development/src/schema/tenant.graphql.cjs +28 -0
- package/dist/development/src/schema/tenant.graphql.cjs.map +1 -1
- package/dist/development/src/schema/tenant.graphql.js +28 -0
- package/dist/development/src/schema/tenant.graphql.js.map +1 -1
- package/dist/production/index.cjs +2 -0
- package/dist/production/index.cjs.map +1 -1
- package/dist/production/index.js +2 -0
- package/dist/production/index.js.map +1 -1
- package/dist/production/src/migrations/2026-09-04-120000-idp-disable-local-authentication.cjs +18 -0
- package/dist/production/src/migrations/2026-09-04-120000-idp-disable-local-authentication.cjs.map +1 -0
- package/dist/production/src/migrations/2026-09-04-120000-idp-disable-local-authentication.js +19 -0
- package/dist/production/src/migrations/2026-09-04-120000-idp-disable-local-authentication.js.map +1 -0
- package/dist/production/src/migrations/runner.cjs +3 -1
- package/dist/production/src/migrations/runner.cjs.map +1 -1
- package/dist/production/src/migrations/runner.js +3 -1
- package/dist/production/src/migrations/runner.js.map +1 -1
- package/dist/production/src/migrations/snapshot.cjs +2 -1
- package/dist/production/src/migrations/snapshot.cjs.map +1 -1
- package/dist/production/src/migrations/snapshot.js +2 -1
- package/dist/production/src/migrations/snapshot.js.map +1 -1
- package/dist/production/src/model/commands/idp/ClaimIdpRevalidationCommand.cjs +3 -7
- package/dist/production/src/model/commands/idp/ClaimIdpRevalidationCommand.cjs.map +1 -1
- package/dist/production/src/model/commands/idp/ClaimIdpRevalidationCommand.js +3 -7
- package/dist/production/src/model/commands/idp/ClaimIdpRevalidationCommand.js.map +1 -1
- package/dist/production/src/model/commands/idp/CreateIdpCommand.cjs +8 -2
- package/dist/production/src/model/commands/idp/CreateIdpCommand.cjs.map +1 -1
- package/dist/production/src/model/commands/idp/CreateIdpCommand.js +8 -2
- package/dist/production/src/model/commands/idp/CreateIdpCommand.js.map +1 -1
- package/dist/production/src/model/commands/idp/UpdateIdpCommand.cjs +6 -2
- package/dist/production/src/model/commands/idp/UpdateIdpCommand.cjs.map +1 -1
- package/dist/production/src/model/commands/idp/UpdateIdpCommand.js +6 -2
- package/dist/production/src/model/commands/idp/UpdateIdpCommand.js.map +1 -1
- package/dist/production/src/model/queries/idp/LocalAuthDisablingIdpsQuery.cjs +21 -0
- package/dist/production/src/model/queries/idp/LocalAuthDisablingIdpsQuery.cjs.map +1 -0
- package/dist/production/src/model/queries/idp/LocalAuthDisablingIdpsQuery.js +21 -0
- package/dist/production/src/model/queries/idp/LocalAuthDisablingIdpsQuery.js.map +1 -0
- package/dist/production/src/model/queries/idp/PersonIdentityProvidersQuery.cjs +1 -1
- package/dist/production/src/model/queries/idp/PersonIdentityProvidersQuery.cjs.map +1 -1
- package/dist/production/src/model/queries/idp/PersonIdentityProvidersQuery.js +1 -1
- package/dist/production/src/model/queries/idp/PersonIdentityProvidersQuery.js.map +1 -1
- package/dist/production/src/model/queries/idp/helpers.cjs +3 -3
- package/dist/production/src/model/queries/idp/helpers.cjs.map +1 -1
- package/dist/production/src/model/queries/idp/helpers.js +3 -3
- package/dist/production/src/model/queries/idp/helpers.js.map +1 -1
- package/dist/production/src/model/service/PasswordResetManager.cjs +5 -0
- package/dist/production/src/model/service/PasswordResetManager.cjs.map +1 -1
- package/dist/production/src/model/service/PasswordResetManager.js +5 -0
- package/dist/production/src/model/service/PasswordResetManager.js.map +1 -1
- package/dist/production/src/model/service/PasswordlessSignInManager.cjs +19 -0
- package/dist/production/src/model/service/PasswordlessSignInManager.cjs.map +1 -1
- package/dist/production/src/model/service/PasswordlessSignInManager.js +19 -0
- package/dist/production/src/model/service/PasswordlessSignInManager.js.map +1 -1
- package/dist/production/src/model/service/SignInManager.cjs +5 -0
- package/dist/production/src/model/service/SignInManager.cjs.map +1 -1
- package/dist/production/src/model/service/SignInManager.js +5 -0
- package/dist/production/src/model/service/SignInManager.js.map +1 -1
- package/dist/production/src/model/service/idp/PersonIdentityProviderManager.cjs +6 -0
- package/dist/production/src/model/service/idp/PersonIdentityProviderManager.cjs.map +1 -1
- package/dist/production/src/model/service/idp/PersonIdentityProviderManager.js +6 -0
- package/dist/production/src/model/service/idp/PersonIdentityProviderManager.js.map +1 -1
- package/dist/production/src/resolvers/mutation/idp/AddIDPMutationResolver.cjs +2 -1
- package/dist/production/src/resolvers/mutation/idp/AddIDPMutationResolver.cjs.map +1 -1
- package/dist/production/src/resolvers/mutation/idp/AddIDPMutationResolver.js +2 -1
- package/dist/production/src/resolvers/mutation/idp/AddIDPMutationResolver.js.map +1 -1
- package/dist/production/src/resolvers/mutation/idp/UpdateIDPMutationResolver.cjs +2 -1
- package/dist/production/src/resolvers/mutation/idp/UpdateIDPMutationResolver.cjs.map +1 -1
- package/dist/production/src/resolvers/mutation/idp/UpdateIDPMutationResolver.js +2 -1
- package/dist/production/src/resolvers/mutation/idp/UpdateIDPMutationResolver.js.map +1 -1
- package/dist/production/src/resolvers/mutation/idp/audit.cjs +1 -0
- package/dist/production/src/resolvers/mutation/idp/audit.cjs.map +1 -1
- package/dist/production/src/resolvers/mutation/idp/audit.js +1 -0
- package/dist/production/src/resolvers/mutation/idp/audit.js.map +1 -1
- package/dist/production/src/resolvers/mutation/person/PasswordlessMutationResolver.cjs +4 -0
- package/dist/production/src/resolvers/mutation/person/PasswordlessMutationResolver.cjs.map +1 -1
- package/dist/production/src/resolvers/mutation/person/PasswordlessMutationResolver.js +4 -0
- package/dist/production/src/resolvers/mutation/person/PasswordlessMutationResolver.js.map +1 -1
- package/dist/production/src/resolvers/mutation/person/SignInMutationResolver.cjs +2 -2
- package/dist/production/src/resolvers/mutation/person/SignInMutationResolver.cjs.map +1 -1
- package/dist/production/src/resolvers/mutation/person/SignInMutationResolver.js +2 -2
- package/dist/production/src/resolvers/mutation/person/SignInMutationResolver.js.map +1 -1
- package/dist/production/src/schema/tenant.graphql.cjs +28 -0
- package/dist/production/src/schema/tenant.graphql.cjs.map +1 -1
- package/dist/production/src/schema/tenant.graphql.js +28 -0
- package/dist/production/src/schema/tenant.graphql.js.map +1 -1
- package/dist/tests/cases/integration/mocked/gql/passwordless.d.ts +3 -0
- package/dist/tests/cases/integration/mocked/gql/passwordless.d.ts.map +1 -1
- package/dist/tests/cases/integration/mocked/localAuthenticationDisabled.test.d.ts +2 -0
- package/dist/tests/cases/integration/mocked/localAuthenticationDisabled.test.d.ts.map +1 -0
- package/dist/tests/cases/integration/mocked/sql/localAuthDisablingIdpsSql.d.ts +10 -0
- package/dist/tests/cases/integration/mocked/sql/localAuthDisablingIdpsSql.d.ts.map +1 -0
- package/dist/tests/cases/integration/mocked/sql/personIdentityProvidersSql.d.ts +1 -0
- package/dist/tests/cases/integration/mocked/sql/personIdentityProvidersSql.d.ts.map +1 -1
- package/dist/tests/cases/unit/idp/claimIdpRevalidation.test.d.ts +2 -0
- package/dist/tests/cases/unit/idp/claimIdpRevalidation.test.d.ts.map +1 -0
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/dist/types/migrations/2026-09-04-120000-idp-disable-local-authentication.d.ts +3 -0
- package/dist/types/migrations/2026-09-04-120000-idp-disable-local-authentication.d.ts.map +1 -0
- package/dist/types/migrations/runner.d.ts.map +1 -1
- package/dist/types/migrations/snapshot.d.ts.map +1 -1
- package/dist/types/model/commands/idp/ClaimIdpRevalidationCommand.d.ts.map +1 -1
- package/dist/types/model/commands/idp/CreateIdpCommand.d.ts.map +1 -1
- package/dist/types/model/commands/idp/UpdateIdpCommand.d.ts.map +1 -1
- package/dist/types/model/queries/idp/LocalAuthDisablingIdpsQuery.d.ts +18 -0
- package/dist/types/model/queries/idp/LocalAuthDisablingIdpsQuery.d.ts.map +1 -0
- package/dist/types/model/queries/idp/PersonIdentityProvidersQuery.d.ts +1 -0
- package/dist/types/model/queries/idp/PersonIdentityProvidersQuery.d.ts.map +1 -1
- package/dist/types/model/queries/idp/helpers.d.ts +1 -1
- package/dist/types/model/queries/idp/helpers.d.ts.map +1 -1
- package/dist/types/model/queries/idp/index.d.ts +1 -0
- package/dist/types/model/queries/idp/index.d.ts.map +1 -1
- package/dist/types/model/queries/idp/types.d.ts +1 -0
- package/dist/types/model/queries/idp/types.d.ts.map +1 -1
- package/dist/types/model/service/PasswordResetManager.d.ts.map +1 -1
- package/dist/types/model/service/PasswordlessSignInManager.d.ts.map +1 -1
- package/dist/types/model/service/SignInManager.d.ts.map +1 -1
- package/dist/types/model/service/idp/PersonIdentityProviderManager.d.ts.map +1 -1
- package/dist/types/model/type/IdentityProvider.d.ts +2 -0
- package/dist/types/model/type/IdentityProvider.d.ts.map +1 -1
- package/dist/types/resolvers/mutation/idp/AddIDPMutationResolver.d.ts.map +1 -1
- package/dist/types/resolvers/mutation/idp/UpdateIDPMutationResolver.d.ts.map +1 -1
- package/dist/types/resolvers/mutation/idp/audit.d.ts.map +1 -1
- package/dist/types/resolvers/mutation/person/PasswordlessMutationResolver.d.ts.map +1 -1
- package/dist/types/schema/index.d.ts +33 -4
- package/dist/types/schema/index.d.ts.map +1 -1
- package/dist/types/schema/tenant.graphql.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -12
- package/src/migrations/2026-09-04-120000-idp-disable-local-authentication.ts +18 -0
- package/src/migrations/runner.ts +2 -0
- package/src/migrations/snapshot.ts +2 -1
- package/src/model/commands/idp/ClaimIdpRevalidationCommand.ts +8 -7
- package/src/model/commands/idp/CreateIdpCommand.ts +7 -1
- package/src/model/commands/idp/UpdateIdpCommand.ts +5 -1
- package/src/model/queries/idp/LocalAuthDisablingIdpsQuery.ts +41 -0
- package/src/model/queries/idp/PersonIdentityProvidersQuery.ts +2 -0
- package/src/model/queries/idp/helpers.ts +3 -2
- package/src/model/queries/idp/index.ts +1 -0
- package/src/model/queries/idp/types.ts +1 -0
- package/src/model/service/PasswordResetManager.ts +9 -1
- package/src/model/service/PasswordlessSignInManager.ts +23 -1
- package/src/model/service/SignInManager.ts +9 -1
- package/src/model/service/idp/PersonIdentityProviderManager.ts +8 -0
- package/src/model/type/IdentityProvider.ts +2 -0
- package/src/resolvers/mutation/idp/AddIDPMutationResolver.ts +1 -0
- package/src/resolvers/mutation/idp/UpdateIDPMutationResolver.ts +1 -0
- package/src/resolvers/mutation/idp/audit.ts +1 -0
- package/src/resolvers/mutation/person/PasswordlessMutationResolver.ts +7 -0
- package/src/resolvers/mutation/person/SignInMutationResolver.ts +2 -2
- package/src/schema/index.ts +29 -0
- package/src/schema/tenant.graphql.ts +28 -0
- package/tests/cases/integration/mocked/gql/passwordless.ts +10 -0
- package/tests/cases/integration/mocked/idpClaimMappingConfig.test.ts +4 -4
- package/tests/cases/integration/mocked/localAuthenticationDisabled.test.ts +182 -0
- package/tests/cases/integration/mocked/personIdentityProviders.test.ts +67 -0
- package/tests/cases/integration/mocked/signInAnomalyDetection.test.ts +11 -0
- package/tests/cases/integration/mocked/signInIdpClaimMapping.test.ts +14 -0
- package/tests/cases/integration/mocked/signInIdpClaimMappingFixes.test.ts +4 -0
- package/tests/cases/integration/mocked/signInIdpClaimMappingSafety.test.ts +6 -0
- package/tests/cases/integration/mocked/signInIdpMutation.test.ts +5 -0
- package/tests/cases/integration/mocked/signInMfaEnforcement.test.ts +7 -0
- package/tests/cases/integration/mocked/signInMutation.test.ts +14 -0
- package/tests/cases/integration/mocked/signInPasswordlessMutation.test.ts +36 -0
- package/tests/cases/integration/mocked/sql/getIdpBySlugSql.ts +1 -1
- package/tests/cases/integration/mocked/sql/localAuthDisablingIdpsSql.ts +21 -0
- package/tests/cases/integration/mocked/sql/personIdentityProvidersSql.ts +3 -1
- package/tests/cases/integration/mocked/trustForwardedClientInfo.test.ts +3 -0
- package/tests/cases/unit/idp/claimIdpRevalidation.test.ts +50 -0
- package/tests/cases/unit/idp/idpManagerUnknownConfigKeys.test.ts +8 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PasswordResetManager.cjs","sources":["../../../../../../packages/engine-tenant-api/src/model/service/PasswordResetManager.ts"],"sourcesContent":["import { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { UserMailer } from '../mailing/index.js'\nimport { ConfigurationQuery, PersonRow } from '../queries/index.js'\nimport { PermissionContext } from '../authorization/index.js'\nimport { ProjectManager } from './ProjectManager.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { getPreferredProject } from './helpers/getPreferredProject.js'\nimport { CreatePersonTokenCommand, ResetPasswordCommand } from '../commands/personToken/index.js'\nimport { PasswordStrengthValidator } from './PasswordStrengthValidator.js'\nimport { ResetPasswordErrorCode, WeakPasswordReason } from '../../schema/index.js'\nimport { PersonTokenQuery } from '../queries/personToken/PersonTokenQuery.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { NextMailAttemptQuery } from '../queries/authLog/NextMailAttemptQuery.js'\n\ninterface MailOptions {\n\tproject?: string\n\tmailVariant?: string\n}\n\nexport class PasswordResetManager {\n\tconstructor(\n\t\tprivate readonly mailer: UserMailer,\n\t\tprivate readonly projectManager: ProjectManager,\n\t\tprivate readonly passwordStrengthValidator: PasswordStrengthValidator,\n\t) {}\n\n\tpublic async createPasswordResetRequest(\n\t\tdbContext: DatabaseContext,\n\t\tpermissionContext: PermissionContext,\n\t\tperson: PersonRow,\n\t\tmailOptions: MailOptions = {},\n\t): Promise<void> {\n\t\tif (!person.email) {\n\t\t\tthrow new ImplementationException()\n\t\t}\n\n\t\t// Per-email exponential backoff on outbound mails — don't spam a real\n\t\t// user with reset mails just because attackers keep poking the\n\t\t// endpoint. Reuses login_* backoff config against person_auth_log.\n\t\tconst nextAllowed = await dbContext.queryHandler.fetch(\n\t\t\tnew NextMailAttemptQuery(person.email, 'password_reset_init', 'password_reset'),\n\t\t)\n\t\tif (nextAllowed > new Date()) {\n\t\t\treturn\n\t\t}\n\n\t\tconst result = await dbContext.commandBus.execute(CreatePersonTokenCommand.createPasswordResetRequest(person.id))\n\t\tconst projects = await this.projectManager.getProjectsByIdentity(dbContext, person.identity_id, permissionContext)\n\t\tconst project = getPreferredProject(projects, mailOptions.project ?? null)\n\n\t\tawait this.mailer.sendPasswordResetEmail(\n\t\t\tdbContext,\n\t\t\t{\n\t\t\t\temail: person.email,\n\t\t\t\ttoken: result.token,\n\t\t\t\tproject: project?.name,\n\t\t\t\tprojectSlug: project?.slug,\n\t\t\t},\n\t\t\t{\n\t\t\t\tvariant: mailOptions.mailVariant || '',\n\t\t\t\tprojectId: project?.id ?? null,\n\t\t\t},\n\t\t)\n\t}\n\n\tpublic async resetPassword(dbContext: DatabaseContext, token: string, password: string): Promise<ResetPasswordResponse> {\n\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\t\tconst tokenRow = await dbContext.queryHandler.fetch(PersonTokenQuery.byToken(token, 'password_reset'))\n\t\tif (!tokenRow) {\n\t\t\treturn new ResponseError('TOKEN_NOT_FOUND', 'Token not found', {\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({}),\n\t\t\t})\n\t\t}\n\n\t\tconst authLogData = new AuthLogService.Bag({\n\t\t\tpersonId: tokenRow.person_id,\n\t\t\ttokenId: tokenRow.id,\n\t\t})\n\t\tconst weakPassword = await this.passwordStrengthValidator.verify(password, config.password, 'PASSWORD_TOO_WEAK')\n\t\tif (!weakPassword.ok) {\n\t\t\treturn new ResponseError(weakPassword.error, weakPassword.errorMessage, {\n\t\t\t\t...weakPassword.metadata ?? {},\n\t\t\t\t[AuthLogService.Key]: authLogData,\n\t\t\t})\n\t\t}\n\t\tconst resetPasswordResult = await dbContext.commandBus.execute(new ResetPasswordCommand(token, password))\n\t\tif (!resetPasswordResult.ok) {\n\t\t\treturn new ResponseError(resetPasswordResult.error, resetPasswordResult.errorMessage, {\n\t\t\t\t...resetPasswordResult.metadata ?? {},\n\t\t\t\t[AuthLogService.Key]: authLogData,\n\t\t\t})\n\t\t}\n\t\treturn new ResponseOk({\n\t\t\t[AuthLogService.Key]: authLogData,\n\t\t})\n\t}\n}\n\nexport type ResetPasswordResponse = Response<\n\t{\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t},\n\tResetPasswordErrorCode,\n\t{\n\t\tweakPasswordReasons?: WeakPasswordReason[]\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n>\n"],"names":["ImplementationException","NextMailAttemptQuery","CreatePersonTokenCommand","getPreferredProject","ConfigurationQuery","PersonTokenQuery","ResponseError","AuthLogService","ResetPasswordCommand","ResponseOk"],"mappings":"
|
|
1
|
+
{"version":3,"file":"PasswordResetManager.cjs","sources":["../../../../../../packages/engine-tenant-api/src/model/service/PasswordResetManager.ts"],"sourcesContent":["import { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { UserMailer } from '../mailing/index.js'\nimport { ConfigurationQuery, LocalAuthDisablingIdpsQuery, PersonRow } from '../queries/index.js'\nimport { PermissionContext } from '../authorization/index.js'\nimport { ProjectManager } from './ProjectManager.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { getPreferredProject } from './helpers/getPreferredProject.js'\nimport { CreatePersonTokenCommand, ResetPasswordCommand } from '../commands/personToken/index.js'\nimport { PasswordStrengthValidator } from './PasswordStrengthValidator.js'\nimport { ResetPasswordErrorCode, WeakPasswordReason } from '../../schema/index.js'\nimport { PersonTokenQuery } from '../queries/personToken/PersonTokenQuery.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { NextMailAttemptQuery } from '../queries/authLog/NextMailAttemptQuery.js'\n\ninterface MailOptions {\n\tproject?: string\n\tmailVariant?: string\n}\n\nexport class PasswordResetManager {\n\tconstructor(\n\t\tprivate readonly mailer: UserMailer,\n\t\tprivate readonly projectManager: ProjectManager,\n\t\tprivate readonly passwordStrengthValidator: PasswordStrengthValidator,\n\t) {}\n\n\tpublic async createPasswordResetRequest(\n\t\tdbContext: DatabaseContext,\n\t\tpermissionContext: PermissionContext,\n\t\tperson: PersonRow,\n\t\tmailOptions: MailOptions = {},\n\t): Promise<void> {\n\t\tif (!person.email) {\n\t\t\tthrow new ImplementationException()\n\t\t}\n\n\t\t// A person federated through a provider with `disableLocalAuthentication`\n\t\t// cannot sign in with a password, so a reset mail would only offer them a\n\t\t// credential the tenant refuses — and one more link worth phishing.\n\t\tconst exclusiveIdps = await dbContext.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(person.id))\n\t\tif (exclusiveIdps.length > 0) {\n\t\t\treturn\n\t\t}\n\n\t\t// Per-email exponential backoff on outbound mails — don't spam a real\n\t\t// user with reset mails just because attackers keep poking the\n\t\t// endpoint. Reuses login_* backoff config against person_auth_log.\n\t\tconst nextAllowed = await dbContext.queryHandler.fetch(\n\t\t\tnew NextMailAttemptQuery(person.email, 'password_reset_init', 'password_reset'),\n\t\t)\n\t\tif (nextAllowed > new Date()) {\n\t\t\treturn\n\t\t}\n\n\t\tconst result = await dbContext.commandBus.execute(CreatePersonTokenCommand.createPasswordResetRequest(person.id))\n\t\tconst projects = await this.projectManager.getProjectsByIdentity(dbContext, person.identity_id, permissionContext)\n\t\tconst project = getPreferredProject(projects, mailOptions.project ?? null)\n\n\t\tawait this.mailer.sendPasswordResetEmail(\n\t\t\tdbContext,\n\t\t\t{\n\t\t\t\temail: person.email,\n\t\t\t\ttoken: result.token,\n\t\t\t\tproject: project?.name,\n\t\t\t\tprojectSlug: project?.slug,\n\t\t\t},\n\t\t\t{\n\t\t\t\tvariant: mailOptions.mailVariant || '',\n\t\t\t\tprojectId: project?.id ?? null,\n\t\t\t},\n\t\t)\n\t}\n\n\tpublic async resetPassword(dbContext: DatabaseContext, token: string, password: string): Promise<ResetPasswordResponse> {\n\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\t\tconst tokenRow = await dbContext.queryHandler.fetch(PersonTokenQuery.byToken(token, 'password_reset'))\n\t\tif (!tokenRow) {\n\t\t\treturn new ResponseError('TOKEN_NOT_FOUND', 'Token not found', {\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({}),\n\t\t\t})\n\t\t}\n\n\t\tconst authLogData = new AuthLogService.Bag({\n\t\t\tpersonId: tokenRow.person_id,\n\t\t\ttokenId: tokenRow.id,\n\t\t})\n\t\tconst weakPassword = await this.passwordStrengthValidator.verify(password, config.password, 'PASSWORD_TOO_WEAK')\n\t\tif (!weakPassword.ok) {\n\t\t\treturn new ResponseError(weakPassword.error, weakPassword.errorMessage, {\n\t\t\t\t...weakPassword.metadata ?? {},\n\t\t\t\t[AuthLogService.Key]: authLogData,\n\t\t\t})\n\t\t}\n\t\tconst resetPasswordResult = await dbContext.commandBus.execute(new ResetPasswordCommand(token, password))\n\t\tif (!resetPasswordResult.ok) {\n\t\t\treturn new ResponseError(resetPasswordResult.error, resetPasswordResult.errorMessage, {\n\t\t\t\t...resetPasswordResult.metadata ?? {},\n\t\t\t\t[AuthLogService.Key]: authLogData,\n\t\t\t})\n\t\t}\n\t\treturn new ResponseOk({\n\t\t\t[AuthLogService.Key]: authLogData,\n\t\t})\n\t}\n}\n\nexport type ResetPasswordResponse = Response<\n\t{\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t},\n\tResetPasswordErrorCode,\n\t{\n\t\tweakPasswordReasons?: WeakPasswordReason[]\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n>\n"],"names":["ImplementationException","LocalAuthDisablingIdpsQuery","NextMailAttemptQuery","CreatePersonTokenCommand","getPreferredProject","ConfigurationQuery","PersonTokenQuery","ResponseError","AuthLogService","ResetPasswordCommand","ResponseOk"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAoBO,MAAM,qBAAqB;AAAA,EACjC,YACkB,QACA,gBACA,2BAChB;AAHgB,SAAA,SAAA;AACA,SAAA,iBAAA;AACA,SAAA,4BAAA;AAAA,EACf;AAAA,EAEH,MAAa,2BACZ,WACA,mBACA,QACA,cAA2B,CAAA,GACX;AAChB,QAAI,CAAC,OAAO,OAAO;AAClB,YAAM,IAAIA,WAAAA,wBAAA;AAAA,IACX;AAKA,UAAM,gBAAgB,MAAM,UAAU,aAAa,MAAM,IAAIC,4BAAAA,4BAA4B,OAAO,EAAE,CAAC;AACnG,QAAI,cAAc,SAAS,GAAG;AAC7B;AAAA,IACD;AAKA,UAAM,cAAc,MAAM,UAAU,aAAa;AAAA,MAChD,IAAIC,0CAAqB,OAAO,OAAO,uBAAuB,gBAAgB;AAAA,IAAA;AAE/E,QAAI,cAAc,oBAAI,QAAQ;AAC7B;AAAA,IACD;AAEA,UAAM,SAAS,MAAM,UAAU,WAAW,QAAQC,yBAAAA,yBAAyB,2BAA2B,OAAO,EAAE,CAAC;AAChH,UAAM,WAAW,MAAM,KAAK,eAAe,sBAAsB,WAAW,OAAO,aAAa,iBAAiB;AACjH,UAAM,UAAUC,oBAAAA,oBAAoB,UAAU,YAAY,WAAW,IAAI;AAEzE,UAAM,KAAK,OAAO;AAAA,MACjB;AAAA,MACA;AAAA,QACC,OAAO,OAAO;AAAA,QACd,OAAO,OAAO;AAAA,QACd,SAAS,SAAS;AAAA,QAClB,aAAa,SAAS;AAAA,MAAA;AAAA,MAEvB;AAAA,QACC,SAAS,YAAY,eAAe;AAAA,QACpC,WAAW,SAAS,MAAM;AAAA,MAAA;AAAA,IAC3B;AAAA,EAEF;AAAA,EAEA,MAAa,cAAc,WAA4B,OAAe,UAAkD;AACvH,UAAM,SAAS,MAAM,UAAU,aAAa,MAAM,IAAIC,mBAAAA,mBAAmB,UAAU,SAAS,CAAC;AAC7F,UAAM,WAAW,MAAM,UAAU,aAAa,MAAMC,iBAAAA,iBAAiB,QAAQ,OAAO,gBAAgB,CAAC;AACrG,QAAI,CAAC,UAAU;AACd,aAAO,IAAIC,SAAAA,cAAc,mBAAmB,mBAAmB;AAAA,QAC9D,CAACC,eAAAA,eAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI,CAAA,CAAE;AAAA,MAAA,CAC/C;AAAA,IACF;AAEA,UAAM,cAAc,IAAIA,eAAAA,eAAe,IAAI;AAAA,MAC1C,UAAU,SAAS;AAAA,MACnB,SAAS,SAAS;AAAA,IAAA,CAClB;AACD,UAAM,eAAe,MAAM,KAAK,0BAA0B,OAAO,UAAU,OAAO,UAAU,mBAAmB;AAC/G,QAAI,CAAC,aAAa,IAAI;AACrB,aAAO,IAAID,SAAAA,cAAc,aAAa,OAAO,aAAa,cAAc;AAAA,QACvE,GAAG,aAAa,YAAY,CAAA;AAAA,QAC5B,CAACC,eAAAA,eAAe,GAAG,GAAG;AAAA,MAAA,CACtB;AAAA,IACF;AACA,UAAM,sBAAsB,MAAM,UAAU,WAAW,QAAQ,IAAIC,qBAAAA,qBAAqB,OAAO,QAAQ,CAAC;AACxG,QAAI,CAAC,oBAAoB,IAAI;AAC5B,aAAO,IAAIF,SAAAA,cAAc,oBAAoB,OAAO,oBAAoB,cAAc;AAAA,QACrF,GAAG,oBAAoB,YAAY,CAAA;AAAA,QACnC,CAACC,eAAAA,eAAe,GAAG,GAAG;AAAA,MAAA,CACtB;AAAA,IACF;AACA,WAAO,IAAIE,SAAAA,WAAW;AAAA,MACrB,CAACF,eAAAA,eAAe,GAAG,GAAG;AAAA,IAAA,CACtB;AAAA,EACF;AACD;;"}
|
|
@@ -4,6 +4,7 @@ import "@contember/database";
|
|
|
4
4
|
import "../type/ApiKey.js";
|
|
5
5
|
import { ConfigurationQuery } from "../queries/configuration/ConfigurationQuery.js";
|
|
6
6
|
import { ImplementationException } from "../../exceptions.js";
|
|
7
|
+
import { LocalAuthDisablingIdpsQuery } from "../queries/idp/LocalAuthDisablingIdpsQuery.js";
|
|
7
8
|
import "../authorization/PermissionActions.js";
|
|
8
9
|
import "@contember/authorization";
|
|
9
10
|
import "../authorization/PermissionContext.js";
|
|
@@ -26,6 +27,10 @@ class PasswordResetManager {
|
|
|
26
27
|
if (!person.email) {
|
|
27
28
|
throw new ImplementationException();
|
|
28
29
|
}
|
|
30
|
+
const exclusiveIdps = await dbContext.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(person.id));
|
|
31
|
+
if (exclusiveIdps.length > 0) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
29
34
|
const nextAllowed = await dbContext.queryHandler.fetch(
|
|
30
35
|
new NextMailAttemptQuery(person.email, "password_reset_init", "password_reset")
|
|
31
36
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PasswordResetManager.js","sources":["../../../../../../packages/engine-tenant-api/src/model/service/PasswordResetManager.ts"],"sourcesContent":["import { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { UserMailer } from '../mailing/index.js'\nimport { ConfigurationQuery, PersonRow } from '../queries/index.js'\nimport { PermissionContext } from '../authorization/index.js'\nimport { ProjectManager } from './ProjectManager.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { getPreferredProject } from './helpers/getPreferredProject.js'\nimport { CreatePersonTokenCommand, ResetPasswordCommand } from '../commands/personToken/index.js'\nimport { PasswordStrengthValidator } from './PasswordStrengthValidator.js'\nimport { ResetPasswordErrorCode, WeakPasswordReason } from '../../schema/index.js'\nimport { PersonTokenQuery } from '../queries/personToken/PersonTokenQuery.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { NextMailAttemptQuery } from '../queries/authLog/NextMailAttemptQuery.js'\n\ninterface MailOptions {\n\tproject?: string\n\tmailVariant?: string\n}\n\nexport class PasswordResetManager {\n\tconstructor(\n\t\tprivate readonly mailer: UserMailer,\n\t\tprivate readonly projectManager: ProjectManager,\n\t\tprivate readonly passwordStrengthValidator: PasswordStrengthValidator,\n\t) {}\n\n\tpublic async createPasswordResetRequest(\n\t\tdbContext: DatabaseContext,\n\t\tpermissionContext: PermissionContext,\n\t\tperson: PersonRow,\n\t\tmailOptions: MailOptions = {},\n\t): Promise<void> {\n\t\tif (!person.email) {\n\t\t\tthrow new ImplementationException()\n\t\t}\n\n\t\t// Per-email exponential backoff on outbound mails — don't spam a real\n\t\t// user with reset mails just because attackers keep poking the\n\t\t// endpoint. Reuses login_* backoff config against person_auth_log.\n\t\tconst nextAllowed = await dbContext.queryHandler.fetch(\n\t\t\tnew NextMailAttemptQuery(person.email, 'password_reset_init', 'password_reset'),\n\t\t)\n\t\tif (nextAllowed > new Date()) {\n\t\t\treturn\n\t\t}\n\n\t\tconst result = await dbContext.commandBus.execute(CreatePersonTokenCommand.createPasswordResetRequest(person.id))\n\t\tconst projects = await this.projectManager.getProjectsByIdentity(dbContext, person.identity_id, permissionContext)\n\t\tconst project = getPreferredProject(projects, mailOptions.project ?? null)\n\n\t\tawait this.mailer.sendPasswordResetEmail(\n\t\t\tdbContext,\n\t\t\t{\n\t\t\t\temail: person.email,\n\t\t\t\ttoken: result.token,\n\t\t\t\tproject: project?.name,\n\t\t\t\tprojectSlug: project?.slug,\n\t\t\t},\n\t\t\t{\n\t\t\t\tvariant: mailOptions.mailVariant || '',\n\t\t\t\tprojectId: project?.id ?? null,\n\t\t\t},\n\t\t)\n\t}\n\n\tpublic async resetPassword(dbContext: DatabaseContext, token: string, password: string): Promise<ResetPasswordResponse> {\n\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\t\tconst tokenRow = await dbContext.queryHandler.fetch(PersonTokenQuery.byToken(token, 'password_reset'))\n\t\tif (!tokenRow) {\n\t\t\treturn new ResponseError('TOKEN_NOT_FOUND', 'Token not found', {\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({}),\n\t\t\t})\n\t\t}\n\n\t\tconst authLogData = new AuthLogService.Bag({\n\t\t\tpersonId: tokenRow.person_id,\n\t\t\ttokenId: tokenRow.id,\n\t\t})\n\t\tconst weakPassword = await this.passwordStrengthValidator.verify(password, config.password, 'PASSWORD_TOO_WEAK')\n\t\tif (!weakPassword.ok) {\n\t\t\treturn new ResponseError(weakPassword.error, weakPassword.errorMessage, {\n\t\t\t\t...weakPassword.metadata ?? {},\n\t\t\t\t[AuthLogService.Key]: authLogData,\n\t\t\t})\n\t\t}\n\t\tconst resetPasswordResult = await dbContext.commandBus.execute(new ResetPasswordCommand(token, password))\n\t\tif (!resetPasswordResult.ok) {\n\t\t\treturn new ResponseError(resetPasswordResult.error, resetPasswordResult.errorMessage, {\n\t\t\t\t...resetPasswordResult.metadata ?? {},\n\t\t\t\t[AuthLogService.Key]: authLogData,\n\t\t\t})\n\t\t}\n\t\treturn new ResponseOk({\n\t\t\t[AuthLogService.Key]: authLogData,\n\t\t})\n\t}\n}\n\nexport type ResetPasswordResponse = Response<\n\t{\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t},\n\tResetPasswordErrorCode,\n\t{\n\t\tweakPasswordReasons?: WeakPasswordReason[]\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n>\n"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"PasswordResetManager.js","sources":["../../../../../../packages/engine-tenant-api/src/model/service/PasswordResetManager.ts"],"sourcesContent":["import { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { UserMailer } from '../mailing/index.js'\nimport { ConfigurationQuery, LocalAuthDisablingIdpsQuery, PersonRow } from '../queries/index.js'\nimport { PermissionContext } from '../authorization/index.js'\nimport { ProjectManager } from './ProjectManager.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { getPreferredProject } from './helpers/getPreferredProject.js'\nimport { CreatePersonTokenCommand, ResetPasswordCommand } from '../commands/personToken/index.js'\nimport { PasswordStrengthValidator } from './PasswordStrengthValidator.js'\nimport { ResetPasswordErrorCode, WeakPasswordReason } from '../../schema/index.js'\nimport { PersonTokenQuery } from '../queries/personToken/PersonTokenQuery.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { NextMailAttemptQuery } from '../queries/authLog/NextMailAttemptQuery.js'\n\ninterface MailOptions {\n\tproject?: string\n\tmailVariant?: string\n}\n\nexport class PasswordResetManager {\n\tconstructor(\n\t\tprivate readonly mailer: UserMailer,\n\t\tprivate readonly projectManager: ProjectManager,\n\t\tprivate readonly passwordStrengthValidator: PasswordStrengthValidator,\n\t) {}\n\n\tpublic async createPasswordResetRequest(\n\t\tdbContext: DatabaseContext,\n\t\tpermissionContext: PermissionContext,\n\t\tperson: PersonRow,\n\t\tmailOptions: MailOptions = {},\n\t): Promise<void> {\n\t\tif (!person.email) {\n\t\t\tthrow new ImplementationException()\n\t\t}\n\n\t\t// A person federated through a provider with `disableLocalAuthentication`\n\t\t// cannot sign in with a password, so a reset mail would only offer them a\n\t\t// credential the tenant refuses — and one more link worth phishing.\n\t\tconst exclusiveIdps = await dbContext.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(person.id))\n\t\tif (exclusiveIdps.length > 0) {\n\t\t\treturn\n\t\t}\n\n\t\t// Per-email exponential backoff on outbound mails — don't spam a real\n\t\t// user with reset mails just because attackers keep poking the\n\t\t// endpoint. Reuses login_* backoff config against person_auth_log.\n\t\tconst nextAllowed = await dbContext.queryHandler.fetch(\n\t\t\tnew NextMailAttemptQuery(person.email, 'password_reset_init', 'password_reset'),\n\t\t)\n\t\tif (nextAllowed > new Date()) {\n\t\t\treturn\n\t\t}\n\n\t\tconst result = await dbContext.commandBus.execute(CreatePersonTokenCommand.createPasswordResetRequest(person.id))\n\t\tconst projects = await this.projectManager.getProjectsByIdentity(dbContext, person.identity_id, permissionContext)\n\t\tconst project = getPreferredProject(projects, mailOptions.project ?? null)\n\n\t\tawait this.mailer.sendPasswordResetEmail(\n\t\t\tdbContext,\n\t\t\t{\n\t\t\t\temail: person.email,\n\t\t\t\ttoken: result.token,\n\t\t\t\tproject: project?.name,\n\t\t\t\tprojectSlug: project?.slug,\n\t\t\t},\n\t\t\t{\n\t\t\t\tvariant: mailOptions.mailVariant || '',\n\t\t\t\tprojectId: project?.id ?? null,\n\t\t\t},\n\t\t)\n\t}\n\n\tpublic async resetPassword(dbContext: DatabaseContext, token: string, password: string): Promise<ResetPasswordResponse> {\n\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\t\tconst tokenRow = await dbContext.queryHandler.fetch(PersonTokenQuery.byToken(token, 'password_reset'))\n\t\tif (!tokenRow) {\n\t\t\treturn new ResponseError('TOKEN_NOT_FOUND', 'Token not found', {\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({}),\n\t\t\t})\n\t\t}\n\n\t\tconst authLogData = new AuthLogService.Bag({\n\t\t\tpersonId: tokenRow.person_id,\n\t\t\ttokenId: tokenRow.id,\n\t\t})\n\t\tconst weakPassword = await this.passwordStrengthValidator.verify(password, config.password, 'PASSWORD_TOO_WEAK')\n\t\tif (!weakPassword.ok) {\n\t\t\treturn new ResponseError(weakPassword.error, weakPassword.errorMessage, {\n\t\t\t\t...weakPassword.metadata ?? {},\n\t\t\t\t[AuthLogService.Key]: authLogData,\n\t\t\t})\n\t\t}\n\t\tconst resetPasswordResult = await dbContext.commandBus.execute(new ResetPasswordCommand(token, password))\n\t\tif (!resetPasswordResult.ok) {\n\t\t\treturn new ResponseError(resetPasswordResult.error, resetPasswordResult.errorMessage, {\n\t\t\t\t...resetPasswordResult.metadata ?? {},\n\t\t\t\t[AuthLogService.Key]: authLogData,\n\t\t\t})\n\t\t}\n\t\treturn new ResponseOk({\n\t\t\t[AuthLogService.Key]: authLogData,\n\t\t})\n\t}\n}\n\nexport type ResetPasswordResponse = Response<\n\t{\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t},\n\tResetPasswordErrorCode,\n\t{\n\t\tweakPasswordReasons?: WeakPasswordReason[]\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n>\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;AAoBO,MAAM,qBAAqB;AAAA,EACjC,YACkB,QACA,gBACA,2BAChB;AAHgB,SAAA,SAAA;AACA,SAAA,iBAAA;AACA,SAAA,4BAAA;AAAA,EACf;AAAA,EAEH,MAAa,2BACZ,WACA,mBACA,QACA,cAA2B,CAAA,GACX;AAChB,QAAI,CAAC,OAAO,OAAO;AAClB,YAAM,IAAI,wBAAA;AAAA,IACX;AAKA,UAAM,gBAAgB,MAAM,UAAU,aAAa,MAAM,IAAI,4BAA4B,OAAO,EAAE,CAAC;AACnG,QAAI,cAAc,SAAS,GAAG;AAC7B;AAAA,IACD;AAKA,UAAM,cAAc,MAAM,UAAU,aAAa;AAAA,MAChD,IAAI,qBAAqB,OAAO,OAAO,uBAAuB,gBAAgB;AAAA,IAAA;AAE/E,QAAI,cAAc,oBAAI,QAAQ;AAC7B;AAAA,IACD;AAEA,UAAM,SAAS,MAAM,UAAU,WAAW,QAAQ,yBAAyB,2BAA2B,OAAO,EAAE,CAAC;AAChH,UAAM,WAAW,MAAM,KAAK,eAAe,sBAAsB,WAAW,OAAO,aAAa,iBAAiB;AACjH,UAAM,UAAU,oBAAoB,UAAU,YAAY,WAAW,IAAI;AAEzE,UAAM,KAAK,OAAO;AAAA,MACjB;AAAA,MACA;AAAA,QACC,OAAO,OAAO;AAAA,QACd,OAAO,OAAO;AAAA,QACd,SAAS,SAAS;AAAA,QAClB,aAAa,SAAS;AAAA,MAAA;AAAA,MAEvB;AAAA,QACC,SAAS,YAAY,eAAe;AAAA,QACpC,WAAW,SAAS,MAAM;AAAA,MAAA;AAAA,IAC3B;AAAA,EAEF;AAAA,EAEA,MAAa,cAAc,WAA4B,OAAe,UAAkD;AACvH,UAAM,SAAS,MAAM,UAAU,aAAa,MAAM,IAAI,mBAAmB,UAAU,SAAS,CAAC;AAC7F,UAAM,WAAW,MAAM,UAAU,aAAa,MAAM,iBAAiB,QAAQ,OAAO,gBAAgB,CAAC;AACrG,QAAI,CAAC,UAAU;AACd,aAAO,IAAI,cAAc,mBAAmB,mBAAmB;AAAA,QAC9D,CAAC,eAAe,GAAG,GAAG,IAAI,eAAe,IAAI,CAAA,CAAE;AAAA,MAAA,CAC/C;AAAA,IACF;AAEA,UAAM,cAAc,IAAI,eAAe,IAAI;AAAA,MAC1C,UAAU,SAAS;AAAA,MACnB,SAAS,SAAS;AAAA,IAAA,CAClB;AACD,UAAM,eAAe,MAAM,KAAK,0BAA0B,OAAO,UAAU,OAAO,UAAU,mBAAmB;AAC/G,QAAI,CAAC,aAAa,IAAI;AACrB,aAAO,IAAI,cAAc,aAAa,OAAO,aAAa,cAAc;AAAA,QACvE,GAAG,aAAa,YAAY,CAAA;AAAA,QAC5B,CAAC,eAAe,GAAG,GAAG;AAAA,MAAA,CACtB;AAAA,IACF;AACA,UAAM,sBAAsB,MAAM,UAAU,WAAW,QAAQ,IAAI,qBAAqB,OAAO,QAAQ,CAAC;AACxG,QAAI,CAAC,oBAAoB,IAAI;AAC5B,aAAO,IAAI,cAAc,oBAAoB,OAAO,oBAAoB,cAAc;AAAA,QACrF,GAAG,oBAAoB,YAAY,CAAA;AAAA,QACnC,CAAC,eAAe,GAAG,GAAG;AAAA,MAAA,CACtB;AAAA,IACF;AACA,WAAO,IAAI,WAAW;AAAA,MACrB,CAAC,eAAe,GAAG,GAAG;AAAA,IAAA,CACtB;AAAA,EACF;AACD;"}
|
|
@@ -6,6 +6,7 @@ require("../type/ApiKey.cjs");
|
|
|
6
6
|
const ConfigurationQuery = require("../queries/configuration/ConfigurationQuery.cjs");
|
|
7
7
|
const exceptions = require("../../exceptions.cjs");
|
|
8
8
|
const PersonQuery = require("../queries/person/PersonQuery.cjs");
|
|
9
|
+
const LocalAuthDisablingIdpsQuery = require("../queries/idp/LocalAuthDisablingIdpsQuery.cjs");
|
|
9
10
|
require("../authorization/PermissionActions.cjs");
|
|
10
11
|
require("@contember/authorization");
|
|
11
12
|
require("../authorization/PermissionContext.cjs");
|
|
@@ -48,6 +49,15 @@ class PasswordlessSignInManager {
|
|
|
48
49
|
})
|
|
49
50
|
});
|
|
50
51
|
}
|
|
52
|
+
const exclusiveIdps = await db2.queryHandler.fetch(new LocalAuthDisablingIdpsQuery.LocalAuthDisablingIdpsQuery(person.id));
|
|
53
|
+
if (exclusiveIdps.length > 0) {
|
|
54
|
+
return new Response.ResponseError("IDP_REQUIRED", `Sign in using ${exclusiveIdps.join(" or ")}`, {
|
|
55
|
+
[AuthLogService.AuthLogService.Key]: new AuthLogService.AuthLogService.Bag({
|
|
56
|
+
personInput: email,
|
|
57
|
+
personId: person.id
|
|
58
|
+
})
|
|
59
|
+
});
|
|
60
|
+
}
|
|
51
61
|
if (!isPasswordlessEnabled.isPasswordlessEnabled(configuration.passwordless.enabled, person.passwordless_enabled)) {
|
|
52
62
|
return new Response.ResponseError("PASSWORDLESS_DISABLED", "Passwordless sign-in is disabled for this person", {
|
|
53
63
|
[AuthLogService.AuthLogService.Key]: new AuthLogService.AuthLogService.Bag({
|
|
@@ -149,6 +159,15 @@ class PasswordlessSignInManager {
|
|
|
149
159
|
})
|
|
150
160
|
});
|
|
151
161
|
}
|
|
162
|
+
const exclusiveIdps = await db2.queryHandler.fetch(new LocalAuthDisablingIdpsQuery.LocalAuthDisablingIdpsQuery(personRow.id));
|
|
163
|
+
if (exclusiveIdps.length > 0) {
|
|
164
|
+
return new Response.ResponseError("IDP_REQUIRED", `Sign in using ${exclusiveIdps.join(" or ")}`, {
|
|
165
|
+
[AuthLogService.AuthLogService.Key]: new AuthLogService.AuthLogService.Bag({
|
|
166
|
+
personId: personRow.id,
|
|
167
|
+
tokenId: tokenResult?.id
|
|
168
|
+
})
|
|
169
|
+
});
|
|
170
|
+
}
|
|
152
171
|
let usedBackupCode = false;
|
|
153
172
|
if (personRow.otp_secret && personRow.otp_activated_at) {
|
|
154
173
|
if (mfaOtp) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PasswordlessSignInManager.cjs","sources":["../../../../../../packages/engine-tenant-api/src/model/service/PasswordlessSignInManager.ts"],"sourcesContent":["import { ApiKeyManager } from './apiKey/index.js'\nimport { ConfigurationQuery, PersonQuery, PersonRow } from '../queries/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport {\n\tActivatePasswordlessOtpErrorCode,\n\tInitSignInPasswordlessErrorCode,\n\tInitSignInPasswordlessResult,\n\tSignInPasswordlessErrorCode,\n} from '../../schema/index.js'\nimport { DatabaseContext, validateToken } from '../utils/index.js'\nimport { UserMailer } from '../mailing/index.js'\nimport {\n\tActivateOtpCommand,\n\tApiKeyRequestInfo,\n\tCreatePersonTokenCommand,\n\tIncreaseOtpAttemptCommand,\n\tInvalidateTokenCommand,\n\tMarkEmailVerifiedCommand,\n} from '../commands/index.js'\nimport { getPreferredProject } from './helpers/getPreferredProject.js'\nimport { isPasswordlessEnabled } from './helpers/isPasswordlessEnabled.js'\nimport { ProjectManager } from './ProjectManager.js'\nimport { PermissionContext } from '../authorization/index.js'\nimport { PersonTokenQuery } from '../queries/personToken/PersonTokenQuery.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { OtpManager } from './OtpManager.js'\nimport { BackupCodeManager } from './BackupCodeManager.js'\nimport { PersonToken } from '../type/index.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { intervalToSeconds } from '../utils/interval.js'\nimport { NextMailAttemptQuery } from '../queries/authLog/NextMailAttemptQuery.js'\n\nclass PasswordlessSignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly mailer: UserMailer,\n\t\tprivate readonly projectManager: ProjectManager,\n\t\tprivate readonly otpManager: OtpManager,\n\t\tprivate readonly backupCodeManager: BackupCodeManager,\n\t) {}\n\n\tasync initSignInPasswordless({ db, permissionContext, mailVariant, mailProject, email }: {\n\t\tdb: DatabaseContext\n\t\tpermissionContext: PermissionContext\n\t\temail: string\n\t\tmailVariant?: string\n\t\tmailProject?: string\n\t}): Promise<PasswordlessSignInManager.InitSignInPasswordlessResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.InitSignInPasswordlessResponse> => {\n\t\t\tconst configuration = await db.queryHandler.fetch(new ConfigurationQuery(db.providers))\n\t\t\tif (configuration.passwordless.enabled === 'never') {\n\t\t\t\treturn new ResponseError('PASSWORDLESS_DISABLED', 'Passwordless sign-in is disabled', {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tconst person = await db.queryHandler.fetch(PersonQuery.byEmail(email))\n\t\t\tif (!person) {\n\t\t\t\treturn new ResponseError('PERSON_NOT_FOUND', `Person ${email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tif (!isPasswordlessEnabled(configuration.passwordless.enabled, person.passwordless_enabled)) {\n\t\t\t\treturn new ResponseError('PASSWORDLESS_DISABLED', 'Passwordless sign-in is disabled for this person', {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Per-email exponential backoff on outbound mails — bound the spam\n\t\t\t// pressure on a real user even if attackers spray the endpoint.\n\t\t\t// Reuses login_* backoff config against person_auth_log.\n\t\t\tconst nextAllowed = await db.queryHandler.fetch(\n\t\t\t\tnew NextMailAttemptQuery(email, 'passwordless_login_init', 'passwordless_login'),\n\t\t\t)\n\t\t\tif (nextAllowed > new Date()) {\n\t\t\t\tconst retryAfterSeconds = Math.max(1, Math.ceil((nextAllowed.getTime() - Date.now()) / 1000))\n\t\t\t\treturn new ResponseError(\n\t\t\t\t\t'RATE_LIMIT_EXCEEDED',\n\t\t\t\t\t`Too many passwordless sign-in requests for this email. Retry after ${retryAfterSeconds}s.`,\n\t\t\t\t\t{\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tconst createTokenCommand = CreatePersonTokenCommand.createPasswordlessRequest(\n\t\t\t\tperson.id,\n\t\t\t\tintervalToSeconds(configuration.passwordless.expiration) / 60,\n\t\t\t)\n\t\t\tconst result = await db.commandBus.execute(createTokenCommand)\n\n\t\t\tconst url = this.formatUrl(configuration.passwordless.url ?? null, result.token, result.id, email) ?? undefined\n\n\t\t\tconst projects = await this.projectManager.getProjectsByIdentity(db, person.identity_id, permissionContext)\n\t\t\tconst project = getPreferredProject(projects, mailProject ?? null)\n\n\t\t\tawait this.mailer.sendPasswordlessEmail(db, {\n\t\t\t\temail,\n\t\t\t\ttoken: result.token,\n\t\t\t\turl,\n\t\t\t\tproject: project?.name,\n\t\t\t\tprojectSlug: project?.slug,\n\t\t\t\trequestId: result.id,\n\t\t\t}, {\n\t\t\t\tvariant: mailVariant || '',\n\t\t\t\tprojectId: project?.id ?? null,\n\t\t\t})\n\n\t\t\treturn new ResponseOk({\n\t\t\t\trequestId: result.id,\n\t\t\t\texpiresAt: result.expiresAt,\n\t\t\t\tperson,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonInput: email,\n\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\ttokenId: result.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tprivate formatUrl(url: string | null, token: string, requestId: string, email: string): string | null {\n\t\tif (!url) {\n\t\t\treturn null\n\t\t}\n\t\tconst urlObj = new URL(url)\n\t\turlObj.searchParams.set('token', token)\n\t\turlObj.searchParams.set('request_id', requestId)\n\t\turlObj.searchParams.set('email', email)\n\t\treturn urlObj.toString()\n\t}\n\n\tasync signInPasswordless({ db, expiration, token, requestId, mfaOtp, backupCode, validationType, requestInfo, trustForwardedInfo }: {\n\t\tdb: DatabaseContext\n\t\tvalidationType: PersonToken.ValidationType\n\t\trequestId: string\n\t\ttoken: string\n\t\tmfaOtp?: string\n\t\tbackupCode?: string\n\t\texpiration?: number\n\t\trequestInfo?: ApiKeyRequestInfo\n\t\ttrustForwardedInfo?: boolean\n\t}): Promise<PasswordlessSignInManager.SignInPasswordlessResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.SignInPasswordlessResponse> => {\n\t\t\tconst tokenResult = await db.queryHandler.fetch(PersonTokenQuery.byId(requestId, 'passwordless'))\n\t\t\tconst tokenValidationResult = validateToken({\n\t\t\t\tentry: tokenResult,\n\t\t\t\ttoken,\n\t\t\t\tnow: db.providers.now(),\n\t\t\t\tvalidationType,\n\t\t\t})\n\t\t\tif (!tokenValidationResult.ok) {\n\t\t\t\tif (tokenValidationResult.error === 'TOKEN_INVALID' && validationType === 'otp') {\n\t\t\t\t\tawait db.commandBus.execute(new IncreaseOtpAttemptCommand(tokenResult!.id))\n\t\t\t\t}\n\t\t\t\treturn new ResponseError(tokenValidationResult.error, tokenValidationResult.errorMessage, {\n\t\t\t\t\t...tokenValidationResult.metadata ?? {},\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tif (!tokenResult) {\n\t\t\t\tthrow new ImplementationException()\n\t\t\t}\n\t\t\tconst personRow = await db.queryHandler.fetch(PersonQuery.byId(tokenValidationResult.result.person_id))\n\t\t\tif (!personRow) {\n\t\t\t\tthrow new ImplementationException()\n\t\t\t}\n\t\t\tif (personRow.disabled_at !== null) {\n\t\t\t\treturn new ResponseError('PERSON_DISABLED', `Person is disabled`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tlet usedBackupCode = false\n\t\t\tif (personRow.otp_secret && personRow.otp_activated_at) {\n\t\t\t\tif (mfaOtp) {\n\t\t\t\t\tif (!await this.otpManager.verifyOtp(db, personRow, mfaOtp)) {\n\t\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else if (backupCode) {\n\t\t\t\t\tif (!await this.backupCodeManager.verifyAndConsume(db, personRow, backupCode)) {\n\t\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tusedBackupCode = true\n\t\t\t\t} else {\n\t\t\t\t\treturn new ResponseError('OTP_REQUIRED', `2FA is enabled. OTP token is required`, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\t// NOTE: passwordless sign-in does NOT enforce mandatory-MFA enrollment.\n\t\t\t// A person with no MFA factor is let through even when an auth_policy would\n\t\t\t// require MFA — enroll-via-sign-in is password-only, and passwordless email\n\t\t\t// access is itself a possession factor. An existing factor (TOTP/backup\n\t\t\t// code, handled above) is still verified. Documented limitation (A06).\n\n\t\t\tawait db.commandBus.execute(new InvalidateTokenCommand(tokenValidationResult.result.id))\n\t\t\t// Receiving and clicking the magic link proves ownership of the address,\n\t\t\t// so a successful passwordless sign-in also satisfies the e-mail\n\t\t\t// verification requirement. Without this, a required-but-unverified\n\t\t\t// account could obtain a session here while being blocked from password\n\t\t\t// sign-in (SignInManager) — an inconsistent, exploitable gap.\n\t\t\tconst emailVerifiedNow = personRow.email_verified_at === null\n\t\t\tif (emailVerifiedNow) {\n\t\t\t\tawait db.commandBus.execute(new MarkEmailVerifiedCommand(personRow.id))\n\t\t\t}\n\t\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(db, personRow.identity_id, expiration, requestInfo, trustForwardedInfo)\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: personRow,\n\t\t\t\ttoken: sessionToken,\n\t\t\t\tusedBackupCode,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t// Record that this sign-in also satisfied e-mail verification, so\n\t\t\t\t\t// the audit trail shows when/how the address became verified.\n\t\t\t\t\teventData: emailVerifiedNow ? { emailVerified: true } : undefined,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tasync activatePasswordlessOtp({ db, token, requestId, otpHash }: {\n\t\tdb: DatabaseContext\n\t\trequestId: string\n\t\ttoken: string\n\t\totpHash: string\n\t}): Promise<PasswordlessSignInManager.ActivatePasswordlessOtpResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.ActivatePasswordlessOtpResponse> => {\n\t\t\tconst tokenResult = await db.queryHandler.fetch(PersonTokenQuery.byId(requestId, 'passwordless'))\n\t\t\tconst tokenValidationResult = validateToken({\n\t\t\t\tentry: tokenResult,\n\t\t\t\ttoken,\n\t\t\t\tnow: db.providers.now(),\n\t\t\t\tvalidationType: 'token',\n\t\t\t})\n\t\t\tif (!tokenValidationResult.ok) {\n\t\t\t\treturn new ResponseError(tokenValidationResult.error, tokenValidationResult.errorMessage, {\n\t\t\t\t\t...tokenValidationResult.metadata ?? {},\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tawait db.commandBus.execute(new ActivateOtpCommand(tokenValidationResult.result.id, otpHash))\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: tokenResult?.person_id,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n}\n\nnamespace PasswordlessSignInManager {\n\texport type InitSignInPasswordlessResponse = Response<\n\t\tInitSignInPasswordlessResult & {\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t},\n\t\tInitSignInPasswordlessErrorCode,\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}\n\t>\n\n\tinterface SignInPasswordlessResult {\n\t\treadonly person: PersonRow\n\t\treadonly token: string\n\t\t/** True when MFA was satisfied by consuming a backup code (instead of a TOTP token). */\n\t\treadonly usedBackupCode?: boolean\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n\texport type SignInPasswordlessResponse = Response<SignInPasswordlessResult, SignInPasswordlessErrorCode, {\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n\n\texport type ActivatePasswordlessOtpResponse = Response<\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t},\n\t\tActivatePasswordlessOtpErrorCode,\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}\n\t>\n}\n\nexport { PasswordlessSignInManager }\n"],"names":["db","ConfigurationQuery","ResponseError","AuthLogService","PersonQuery","isPasswordlessEnabled","NextMailAttemptQuery","CreatePersonTokenCommand","intervalToSeconds","getPreferredProject","ResponseOk","token","PersonTokenQuery","validateToken","IncreaseOtpAttemptCommand","ImplementationException","InvalidateTokenCommand","MarkEmailVerifiedCommand","ActivateOtpCommand"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"PasswordlessSignInManager.cjs","sources":["../../../../../../packages/engine-tenant-api/src/model/service/PasswordlessSignInManager.ts"],"sourcesContent":["import { ApiKeyManager } from './apiKey/index.js'\nimport { ConfigurationQuery, LocalAuthDisablingIdpsQuery, PersonQuery, PersonRow } from '../queries/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport {\n\tActivatePasswordlessOtpErrorCode,\n\tInitSignInPasswordlessErrorCode,\n\tInitSignInPasswordlessResult,\n\tSignInPasswordlessErrorCode,\n} from '../../schema/index.js'\nimport { DatabaseContext, validateToken } from '../utils/index.js'\nimport { UserMailer } from '../mailing/index.js'\nimport {\n\tActivateOtpCommand,\n\tApiKeyRequestInfo,\n\tCreatePersonTokenCommand,\n\tIncreaseOtpAttemptCommand,\n\tInvalidateTokenCommand,\n\tMarkEmailVerifiedCommand,\n} from '../commands/index.js'\nimport { getPreferredProject } from './helpers/getPreferredProject.js'\nimport { isPasswordlessEnabled } from './helpers/isPasswordlessEnabled.js'\nimport { ProjectManager } from './ProjectManager.js'\nimport { PermissionContext } from '../authorization/index.js'\nimport { PersonTokenQuery } from '../queries/personToken/PersonTokenQuery.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { OtpManager } from './OtpManager.js'\nimport { BackupCodeManager } from './BackupCodeManager.js'\nimport { PersonToken } from '../type/index.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { intervalToSeconds } from '../utils/interval.js'\nimport { NextMailAttemptQuery } from '../queries/authLog/NextMailAttemptQuery.js'\n\nclass PasswordlessSignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly mailer: UserMailer,\n\t\tprivate readonly projectManager: ProjectManager,\n\t\tprivate readonly otpManager: OtpManager,\n\t\tprivate readonly backupCodeManager: BackupCodeManager,\n\t) {}\n\n\tasync initSignInPasswordless({ db, permissionContext, mailVariant, mailProject, email }: {\n\t\tdb: DatabaseContext\n\t\tpermissionContext: PermissionContext\n\t\temail: string\n\t\tmailVariant?: string\n\t\tmailProject?: string\n\t}): Promise<PasswordlessSignInManager.InitSignInPasswordlessResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.InitSignInPasswordlessResponse> => {\n\t\t\tconst configuration = await db.queryHandler.fetch(new ConfigurationQuery(db.providers))\n\t\t\tif (configuration.passwordless.enabled === 'never') {\n\t\t\t\treturn new ResponseError('PASSWORDLESS_DISABLED', 'Passwordless sign-in is disabled', {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tconst person = await db.queryHandler.fetch(PersonQuery.byEmail(email))\n\t\t\tif (!person) {\n\t\t\t\treturn new ResponseError('PERSON_NOT_FOUND', `Person ${email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst exclusiveIdps = await db.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(person.id))\n\t\t\tif (exclusiveIdps.length > 0) {\n\t\t\t\treturn new ResponseError('IDP_REQUIRED', `Sign in using ${exclusiveIdps.join(' or ')}`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (!isPasswordlessEnabled(configuration.passwordless.enabled, person.passwordless_enabled)) {\n\t\t\t\treturn new ResponseError('PASSWORDLESS_DISABLED', 'Passwordless sign-in is disabled for this person', {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Per-email exponential backoff on outbound mails — bound the spam\n\t\t\t// pressure on a real user even if attackers spray the endpoint.\n\t\t\t// Reuses login_* backoff config against person_auth_log.\n\t\t\tconst nextAllowed = await db.queryHandler.fetch(\n\t\t\t\tnew NextMailAttemptQuery(email, 'passwordless_login_init', 'passwordless_login'),\n\t\t\t)\n\t\t\tif (nextAllowed > new Date()) {\n\t\t\t\tconst retryAfterSeconds = Math.max(1, Math.ceil((nextAllowed.getTime() - Date.now()) / 1000))\n\t\t\t\treturn new ResponseError(\n\t\t\t\t\t'RATE_LIMIT_EXCEEDED',\n\t\t\t\t\t`Too many passwordless sign-in requests for this email. Retry after ${retryAfterSeconds}s.`,\n\t\t\t\t\t{\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tconst createTokenCommand = CreatePersonTokenCommand.createPasswordlessRequest(\n\t\t\t\tperson.id,\n\t\t\t\tintervalToSeconds(configuration.passwordless.expiration) / 60,\n\t\t\t)\n\t\t\tconst result = await db.commandBus.execute(createTokenCommand)\n\n\t\t\tconst url = this.formatUrl(configuration.passwordless.url ?? null, result.token, result.id, email) ?? undefined\n\n\t\t\tconst projects = await this.projectManager.getProjectsByIdentity(db, person.identity_id, permissionContext)\n\t\t\tconst project = getPreferredProject(projects, mailProject ?? null)\n\n\t\t\tawait this.mailer.sendPasswordlessEmail(db, {\n\t\t\t\temail,\n\t\t\t\ttoken: result.token,\n\t\t\t\turl,\n\t\t\t\tproject: project?.name,\n\t\t\t\tprojectSlug: project?.slug,\n\t\t\t\trequestId: result.id,\n\t\t\t}, {\n\t\t\t\tvariant: mailVariant || '',\n\t\t\t\tprojectId: project?.id ?? null,\n\t\t\t})\n\n\t\t\treturn new ResponseOk({\n\t\t\t\trequestId: result.id,\n\t\t\t\texpiresAt: result.expiresAt,\n\t\t\t\tperson,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonInput: email,\n\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\ttokenId: result.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tprivate formatUrl(url: string | null, token: string, requestId: string, email: string): string | null {\n\t\tif (!url) {\n\t\t\treturn null\n\t\t}\n\t\tconst urlObj = new URL(url)\n\t\turlObj.searchParams.set('token', token)\n\t\turlObj.searchParams.set('request_id', requestId)\n\t\turlObj.searchParams.set('email', email)\n\t\treturn urlObj.toString()\n\t}\n\n\tasync signInPasswordless({ db, expiration, token, requestId, mfaOtp, backupCode, validationType, requestInfo, trustForwardedInfo }: {\n\t\tdb: DatabaseContext\n\t\tvalidationType: PersonToken.ValidationType\n\t\trequestId: string\n\t\ttoken: string\n\t\tmfaOtp?: string\n\t\tbackupCode?: string\n\t\texpiration?: number\n\t\trequestInfo?: ApiKeyRequestInfo\n\t\ttrustForwardedInfo?: boolean\n\t}): Promise<PasswordlessSignInManager.SignInPasswordlessResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.SignInPasswordlessResponse> => {\n\t\t\tconst tokenResult = await db.queryHandler.fetch(PersonTokenQuery.byId(requestId, 'passwordless'))\n\t\t\tconst tokenValidationResult = validateToken({\n\t\t\t\tentry: tokenResult,\n\t\t\t\ttoken,\n\t\t\t\tnow: db.providers.now(),\n\t\t\t\tvalidationType,\n\t\t\t})\n\t\t\tif (!tokenValidationResult.ok) {\n\t\t\t\tif (tokenValidationResult.error === 'TOKEN_INVALID' && validationType === 'otp') {\n\t\t\t\t\tawait db.commandBus.execute(new IncreaseOtpAttemptCommand(tokenResult!.id))\n\t\t\t\t}\n\t\t\t\treturn new ResponseError(tokenValidationResult.error, tokenValidationResult.errorMessage, {\n\t\t\t\t\t...tokenValidationResult.metadata ?? {},\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tif (!tokenResult) {\n\t\t\t\tthrow new ImplementationException()\n\t\t\t}\n\t\t\tconst personRow = await db.queryHandler.fetch(PersonQuery.byId(tokenValidationResult.result.person_id))\n\t\t\tif (!personRow) {\n\t\t\t\tthrow new ImplementationException()\n\t\t\t}\n\t\t\tif (personRow.disabled_at !== null) {\n\t\t\t\treturn new ResponseError('PERSON_DISABLED', `Person is disabled`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Re-checked here and not only at init: a link created while this magic link\n\t\t\t// was in flight would otherwise still mint a locally authenticated session.\n\t\t\tconst exclusiveIdps = await db.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(personRow.id))\n\t\t\tif (exclusiveIdps.length > 0) {\n\t\t\t\treturn new ResponseError('IDP_REQUIRED', `Sign in using ${exclusiveIdps.join(' or ')}`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tlet usedBackupCode = false\n\t\t\tif (personRow.otp_secret && personRow.otp_activated_at) {\n\t\t\t\tif (mfaOtp) {\n\t\t\t\t\tif (!await this.otpManager.verifyOtp(db, personRow, mfaOtp)) {\n\t\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else if (backupCode) {\n\t\t\t\t\tif (!await this.backupCodeManager.verifyAndConsume(db, personRow, backupCode)) {\n\t\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tusedBackupCode = true\n\t\t\t\t} else {\n\t\t\t\t\treturn new ResponseError('OTP_REQUIRED', `2FA is enabled. OTP token is required`, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\t// NOTE: passwordless sign-in does NOT enforce mandatory-MFA enrollment.\n\t\t\t// A person with no MFA factor is let through even when an auth_policy would\n\t\t\t// require MFA — enroll-via-sign-in is password-only, and passwordless email\n\t\t\t// access is itself a possession factor. An existing factor (TOTP/backup\n\t\t\t// code, handled above) is still verified. Documented limitation (A06).\n\n\t\t\tawait db.commandBus.execute(new InvalidateTokenCommand(tokenValidationResult.result.id))\n\t\t\t// Receiving and clicking the magic link proves ownership of the address,\n\t\t\t// so a successful passwordless sign-in also satisfies the e-mail\n\t\t\t// verification requirement. Without this, a required-but-unverified\n\t\t\t// account could obtain a session here while being blocked from password\n\t\t\t// sign-in (SignInManager) — an inconsistent, exploitable gap.\n\t\t\tconst emailVerifiedNow = personRow.email_verified_at === null\n\t\t\tif (emailVerifiedNow) {\n\t\t\t\tawait db.commandBus.execute(new MarkEmailVerifiedCommand(personRow.id))\n\t\t\t}\n\t\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(db, personRow.identity_id, expiration, requestInfo, trustForwardedInfo)\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: personRow,\n\t\t\t\ttoken: sessionToken,\n\t\t\t\tusedBackupCode,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t// Record that this sign-in also satisfied e-mail verification, so\n\t\t\t\t\t// the audit trail shows when/how the address became verified.\n\t\t\t\t\teventData: emailVerifiedNow ? { emailVerified: true } : undefined,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tasync activatePasswordlessOtp({ db, token, requestId, otpHash }: {\n\t\tdb: DatabaseContext\n\t\trequestId: string\n\t\ttoken: string\n\t\totpHash: string\n\t}): Promise<PasswordlessSignInManager.ActivatePasswordlessOtpResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.ActivatePasswordlessOtpResponse> => {\n\t\t\tconst tokenResult = await db.queryHandler.fetch(PersonTokenQuery.byId(requestId, 'passwordless'))\n\t\t\tconst tokenValidationResult = validateToken({\n\t\t\t\tentry: tokenResult,\n\t\t\t\ttoken,\n\t\t\t\tnow: db.providers.now(),\n\t\t\t\tvalidationType: 'token',\n\t\t\t})\n\t\t\tif (!tokenValidationResult.ok) {\n\t\t\t\treturn new ResponseError(tokenValidationResult.error, tokenValidationResult.errorMessage, {\n\t\t\t\t\t...tokenValidationResult.metadata ?? {},\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tawait db.commandBus.execute(new ActivateOtpCommand(tokenValidationResult.result.id, otpHash))\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: tokenResult?.person_id,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n}\n\nnamespace PasswordlessSignInManager {\n\texport type InitSignInPasswordlessResponse = Response<\n\t\tInitSignInPasswordlessResult & {\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t},\n\t\tInitSignInPasswordlessErrorCode,\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}\n\t>\n\n\tinterface SignInPasswordlessResult {\n\t\treadonly person: PersonRow\n\t\treadonly token: string\n\t\t/** True when MFA was satisfied by consuming a backup code (instead of a TOTP token). */\n\t\treadonly usedBackupCode?: boolean\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n\texport type SignInPasswordlessResponse = Response<SignInPasswordlessResult, SignInPasswordlessErrorCode, {\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n\n\texport type ActivatePasswordlessOtpResponse = Response<\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t},\n\t\tActivatePasswordlessOtpErrorCode,\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}\n\t>\n}\n\nexport { PasswordlessSignInManager }\n"],"names":["db","ConfigurationQuery","ResponseError","AuthLogService","PersonQuery","LocalAuthDisablingIdpsQuery","isPasswordlessEnabled","NextMailAttemptQuery","CreatePersonTokenCommand","intervalToSeconds","getPreferredProject","ResponseOk","token","PersonTokenQuery","validateToken","IncreaseOtpAttemptCommand","ImplementationException","InvalidateTokenCommand","MarkEmailVerifiedCommand","ActivateOtpCommand"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAM,0BAA0B;AAAA,EAC/B,YACkB,eACA,QACA,gBACA,YACA,mBAChB;AALgB,SAAA,gBAAA;AACA,SAAA,SAAA;AACA,SAAA,iBAAA;AACA,SAAA,aAAA;AACA,SAAA,oBAAA;AAAA,EACf;AAAA,EAEH,MAAM,uBAAuB,EAAE,IAAI,mBAAmB,aAAa,aAAa,SAMV;AACrE,WAAO,GAAG,YAAY,OAAOA,QAA0E;AACtG,YAAM,gBAAgB,MAAMA,IAAG,aAAa,MAAM,IAAIC,mBAAAA,mBAAmBD,IAAG,SAAS,CAAC;AACtF,UAAI,cAAc,aAAa,YAAY,SAAS;AACnD,eAAO,IAAIE,SAAAA,cAAc,yBAAyB,oCAAoC;AAAA,UACrF,CAACC,eAAAA,eAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI,CAAA,CAAE;AAAA,QAAA,CAC/C;AAAA,MACF;AAEA,YAAM,SAAS,MAAMH,IAAG,aAAa,MAAMI,wBAAY,QAAQ,KAAK,CAAC;AACrE,UAAI,CAAC,QAAQ;AACZ,eAAO,IAAIF,SAAAA,cAAc,oBAAoB,UAAU,KAAK,cAAc;AAAA,UACzE,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,YAC5C,aAAa;AAAA,UAAA,CACb;AAAA,QAAA,CACD;AAAA,MACF;AACA,YAAM,gBAAgB,MAAMH,IAAG,aAAa,MAAM,IAAIK,4BAAAA,4BAA4B,OAAO,EAAE,CAAC;AAC5F,UAAI,cAAc,SAAS,GAAG;AAC7B,eAAO,IAAIH,SAAAA,cAAc,gBAAgB,iBAAiB,cAAc,KAAK,MAAM,CAAC,IAAI;AAAA,UACvF,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,YAC5C,aAAa;AAAA,YACb,UAAU,OAAO;AAAA,UAAA,CACjB;AAAA,QAAA,CACD;AAAA,MACF;AAEA,UAAI,CAACG,sBAAAA,sBAAsB,cAAc,aAAa,SAAS,OAAO,oBAAoB,GAAG;AAC5F,eAAO,IAAIJ,SAAAA,cAAc,yBAAyB,oDAAoD;AAAA,UACrG,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,YAC5C,aAAa;AAAA,YACb,UAAU,OAAO;AAAA,UAAA,CACjB;AAAA,QAAA,CACD;AAAA,MACF;AAKA,YAAM,cAAc,MAAMH,IAAG,aAAa;AAAA,QACzC,IAAIO,qBAAAA,qBAAqB,OAAO,2BAA2B,oBAAoB;AAAA,MAAA;AAEhF,UAAI,cAAc,oBAAI,QAAQ;AAC7B,cAAM,oBAAoB,KAAK,IAAI,GAAG,KAAK,MAAM,YAAY,QAAA,IAAY,KAAK,IAAA,KAAS,GAAI,CAAC;AAC5F,eAAO,IAAIL,SAAAA;AAAAA,UACV;AAAA,UACA,sEAAsE,iBAAiB;AAAA,UACvF;AAAA,YACC,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,cAC5C,aAAa;AAAA,cACb,UAAU,OAAO;AAAA,YAAA,CACjB;AAAA,UAAA;AAAA,QACF;AAAA,MAEF;AAEA,YAAM,qBAAqBK,yBAAAA,yBAAyB;AAAA,QACnD,OAAO;AAAA,QACPC,SAAAA,kBAAkB,cAAc,aAAa,UAAU,IAAI;AAAA,MAAA;AAE5D,YAAM,SAAS,MAAMT,IAAG,WAAW,QAAQ,kBAAkB;AAE7D,YAAM,MAAM,KAAK,UAAU,cAAc,aAAa,OAAO,MAAM,OAAO,OAAO,OAAO,IAAI,KAAK,KAAK;AAEtG,YAAM,WAAW,MAAM,KAAK,eAAe,sBAAsBA,KAAI,OAAO,aAAa,iBAAiB;AAC1G,YAAM,UAAUU,oBAAAA,oBAAoB,UAAU,eAAe,IAAI;AAEjE,YAAM,KAAK,OAAO,sBAAsBV,KAAI;AAAA,QAC3C;AAAA,QACA,OAAO,OAAO;AAAA,QACd;AAAA,QACA,SAAS,SAAS;AAAA,QAClB,aAAa,SAAS;AAAA,QACtB,WAAW,OAAO;AAAA,MAAA,GAChB;AAAA,QACF,SAAS,eAAe;AAAA,QACxB,WAAW,SAAS,MAAM;AAAA,MAAA,CAC1B;AAED,aAAO,IAAIW,SAAAA,WAAW;AAAA,QACrB,WAAW,OAAO;AAAA,QAClB,WAAW,OAAO;AAAA,QAClB;AAAA,QACA,CAACR,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,UAC5C,aAAa;AAAA,UACb,UAAU,OAAO;AAAA,UACjB,SAAS,OAAO;AAAA,QAAA,CAChB;AAAA,MAAA,CACD;AAAA,IACF,CAAC;AAAA,EACF;AAAA,EAEQ,UAAU,KAAoBS,QAAe,WAAmB,OAA8B;AACrG,QAAI,CAAC,KAAK;AACT,aAAO;AAAA,IACR;AACA,UAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,WAAO,aAAa,IAAI,SAASA,MAAK;AACtC,WAAO,aAAa,IAAI,cAAc,SAAS;AAC/C,WAAO,aAAa,IAAI,SAAS,KAAK;AACtC,WAAO,OAAO,SAAA;AAAA,EACf;AAAA,EAEA,MAAM,mBAAmB,EAAE,IAAI,YAAA,OAAYA,SAAO,WAAW,QAAQ,YAAY,gBAAgB,aAAa,mBAAA,GAU5C;AACjE,WAAO,GAAG,YAAY,OAAOZ,QAAsE;AAClG,YAAM,cAAc,MAAMA,IAAG,aAAa,MAAMa,iBAAAA,iBAAiB,KAAK,WAAW,cAAc,CAAC;AAChG,YAAM,wBAAwBC,MAAAA,cAAc;AAAA,QAC3C,OAAO;AAAA,QAAA,OACPF;AAAAA,QACA,KAAKZ,IAAG,UAAU,IAAA;AAAA,QAClB;AAAA,MAAA,CACA;AACD,UAAI,CAAC,sBAAsB,IAAI;AAC9B,YAAI,sBAAsB,UAAU,mBAAmB,mBAAmB,OAAO;AAChF,gBAAMA,IAAG,WAAW,QAAQ,IAAIe,0BAAAA,0BAA0B,YAAa,EAAE,CAAC;AAAA,QAC3E;AACA,eAAO,IAAIb,SAAAA,cAAc,sBAAsB,OAAO,sBAAsB,cAAc;AAAA,UACzF,GAAG,sBAAsB,YAAY,CAAA;AAAA,UACrC,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,YAC5C,UAAU,aAAa;AAAA,YACvB,SAAS,aAAa;AAAA,UAAA,CACtB;AAAA,QAAA,CACD;AAAA,MACF;AACA,UAAI,CAAC,aAAa;AACjB,cAAM,IAAIa,WAAAA,wBAAA;AAAA,MACX;AACA,YAAM,YAAY,MAAMhB,IAAG,aAAa,MAAMI,wBAAY,KAAK,sBAAsB,OAAO,SAAS,CAAC;AACtG,UAAI,CAAC,WAAW;AACf,cAAM,IAAIY,WAAAA,wBAAA;AAAA,MACX;AACA,UAAI,UAAU,gBAAgB,MAAM;AACnC,eAAO,IAAId,SAAAA,cAAc,mBAAmB,sBAAsB;AAAA,UACjE,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,YAC5C,UAAU,UAAU;AAAA,YACpB,SAAS,aAAa;AAAA,UAAA,CACtB;AAAA,QAAA,CACD;AAAA,MACF;AAIA,YAAM,gBAAgB,MAAMH,IAAG,aAAa,MAAM,IAAIK,4BAAAA,4BAA4B,UAAU,EAAE,CAAC;AAC/F,UAAI,cAAc,SAAS,GAAG;AAC7B,eAAO,IAAIH,SAAAA,cAAc,gBAAgB,iBAAiB,cAAc,KAAK,MAAM,CAAC,IAAI;AAAA,UACvF,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,YAC5C,UAAU,UAAU;AAAA,YACpB,SAAS,aAAa;AAAA,UAAA,CACtB;AAAA,QAAA,CACD;AAAA,MACF;AAEA,UAAI,iBAAiB;AACrB,UAAI,UAAU,cAAc,UAAU,kBAAkB;AACvD,YAAI,QAAQ;AACX,cAAI,CAAC,MAAM,KAAK,WAAW,UAAUH,KAAI,WAAW,MAAM,GAAG;AAC5D,mBAAO,IAAIE,SAAAA,cAAc,qBAAqB,mCAAmC;AAAA,cAChF,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,gBAC5C,UAAU,UAAU;AAAA,gBACpB,SAAS,aAAa;AAAA,cAAA,CACtB;AAAA,YAAA,CACD;AAAA,UACF;AAAA,QACD,WAAW,YAAY;AACtB,cAAI,CAAC,MAAM,KAAK,kBAAkB,iBAAiBH,KAAI,WAAW,UAAU,GAAG;AAC9E,mBAAO,IAAIE,SAAAA,cAAc,qBAAqB,mCAAmC;AAAA,cAChF,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,gBAC5C,UAAU,UAAU;AAAA,gBACpB,SAAS,aAAa;AAAA,cAAA,CACtB;AAAA,YAAA,CACD;AAAA,UACF;AACA,2BAAiB;AAAA,QAClB,OAAO;AACN,iBAAO,IAAID,SAAAA,cAAc,gBAAgB,yCAAyC;AAAA,YACjF,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,cAC5C,UAAU,UAAU;AAAA,cACpB,SAAS,aAAa;AAAA,YAAA,CACtB;AAAA,UAAA,CACD;AAAA,QACF;AAAA,MACD;AAOA,YAAMH,IAAG,WAAW,QAAQ,IAAIiB,uBAAAA,uBAAuB,sBAAsB,OAAO,EAAE,CAAC;AAMvF,YAAM,mBAAmB,UAAU,sBAAsB;AACzD,UAAI,kBAAkB;AACrB,cAAMjB,IAAG,WAAW,QAAQ,IAAIkB,yBAAAA,yBAAyB,UAAU,EAAE,CAAC;AAAA,MACvE;AACA,YAAM,eAAe,MAAM,KAAK,cAAc,oBAAoBlB,KAAI,UAAU,aAAa,YAAY,aAAa,kBAAkB;AAExI,aAAO,IAAIW,SAAAA,WAAW;AAAA,QACrB,QAAQ;AAAA,QACR,OAAO;AAAA,QACP;AAAA,QACA,CAACR,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,UAC5C,UAAU,UAAU;AAAA,UACpB,SAAS,aAAa;AAAA;AAAA;AAAA,UAGtB,WAAW,mBAAmB,EAAE,eAAe,SAAS;AAAA,QAAA,CACxD;AAAA,MAAA,CACD;AAAA,IACF,CAAC;AAAA,EACF;AAAA,EAEA,MAAM,wBAAwB,EAAE,WAAIS,SAAO,WAAW,WAKiB;AACtE,WAAO,GAAG,YAAY,OAAOZ,QAA2E;AACvG,YAAM,cAAc,MAAMA,IAAG,aAAa,MAAMa,iBAAAA,iBAAiB,KAAK,WAAW,cAAc,CAAC;AAChG,YAAM,wBAAwBC,MAAAA,cAAc;AAAA,QAC3C,OAAO;AAAA,QAAA,OACPF;AAAAA,QACA,KAAKZ,IAAG,UAAU,IAAA;AAAA,QAClB,gBAAgB;AAAA,MAAA,CAChB;AACD,UAAI,CAAC,sBAAsB,IAAI;AAC9B,eAAO,IAAIE,SAAAA,cAAc,sBAAsB,OAAO,sBAAsB,cAAc;AAAA,UACzF,GAAG,sBAAsB,YAAY,CAAA;AAAA,UACrC,CAACC,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,YAC5C,UAAU,aAAa;AAAA,YACvB,SAAS,aAAa;AAAA,UAAA,CACtB;AAAA,QAAA,CACD;AAAA,MACF;AAEA,YAAMH,IAAG,WAAW,QAAQ,IAAImB,mBAAAA,mBAAmB,sBAAsB,OAAO,IAAI,OAAO,CAAC;AAE5F,aAAO,IAAIR,SAAAA,WAAW;AAAA,QACrB,QAAQ,aAAa;AAAA,QACrB,CAACR,8BAAe,GAAG,GAAG,IAAIA,eAAAA,eAAe,IAAI;AAAA,UAC5C,UAAU,aAAa;AAAA,UACvB,SAAS,aAAa;AAAA,QAAA,CACtB;AAAA,MAAA,CACD;AAAA,IACF,CAAC;AAAA,EACF;AACD;;"}
|
|
@@ -4,6 +4,7 @@ import "../type/ApiKey.js";
|
|
|
4
4
|
import { ConfigurationQuery } from "../queries/configuration/ConfigurationQuery.js";
|
|
5
5
|
import { ImplementationException } from "../../exceptions.js";
|
|
6
6
|
import { PersonQuery } from "../queries/person/PersonQuery.js";
|
|
7
|
+
import { LocalAuthDisablingIdpsQuery } from "../queries/idp/LocalAuthDisablingIdpsQuery.js";
|
|
7
8
|
import "../authorization/PermissionActions.js";
|
|
8
9
|
import "@contember/authorization";
|
|
9
10
|
import "../authorization/PermissionContext.js";
|
|
@@ -46,6 +47,15 @@ class PasswordlessSignInManager {
|
|
|
46
47
|
})
|
|
47
48
|
});
|
|
48
49
|
}
|
|
50
|
+
const exclusiveIdps = await db2.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(person.id));
|
|
51
|
+
if (exclusiveIdps.length > 0) {
|
|
52
|
+
return new ResponseError("IDP_REQUIRED", `Sign in using ${exclusiveIdps.join(" or ")}`, {
|
|
53
|
+
[AuthLogService.Key]: new AuthLogService.Bag({
|
|
54
|
+
personInput: email,
|
|
55
|
+
personId: person.id
|
|
56
|
+
})
|
|
57
|
+
});
|
|
58
|
+
}
|
|
49
59
|
if (!isPasswordlessEnabled(configuration.passwordless.enabled, person.passwordless_enabled)) {
|
|
50
60
|
return new ResponseError("PASSWORDLESS_DISABLED", "Passwordless sign-in is disabled for this person", {
|
|
51
61
|
[AuthLogService.Key]: new AuthLogService.Bag({
|
|
@@ -147,6 +157,15 @@ class PasswordlessSignInManager {
|
|
|
147
157
|
})
|
|
148
158
|
});
|
|
149
159
|
}
|
|
160
|
+
const exclusiveIdps = await db2.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(personRow.id));
|
|
161
|
+
if (exclusiveIdps.length > 0) {
|
|
162
|
+
return new ResponseError("IDP_REQUIRED", `Sign in using ${exclusiveIdps.join(" or ")}`, {
|
|
163
|
+
[AuthLogService.Key]: new AuthLogService.Bag({
|
|
164
|
+
personId: personRow.id,
|
|
165
|
+
tokenId: tokenResult?.id
|
|
166
|
+
})
|
|
167
|
+
});
|
|
168
|
+
}
|
|
150
169
|
let usedBackupCode = false;
|
|
151
170
|
if (personRow.otp_secret && personRow.otp_activated_at) {
|
|
152
171
|
if (mfaOtp) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PasswordlessSignInManager.js","sources":["../../../../../../packages/engine-tenant-api/src/model/service/PasswordlessSignInManager.ts"],"sourcesContent":["import { ApiKeyManager } from './apiKey/index.js'\nimport { ConfigurationQuery, PersonQuery, PersonRow } from '../queries/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport {\n\tActivatePasswordlessOtpErrorCode,\n\tInitSignInPasswordlessErrorCode,\n\tInitSignInPasswordlessResult,\n\tSignInPasswordlessErrorCode,\n} from '../../schema/index.js'\nimport { DatabaseContext, validateToken } from '../utils/index.js'\nimport { UserMailer } from '../mailing/index.js'\nimport {\n\tActivateOtpCommand,\n\tApiKeyRequestInfo,\n\tCreatePersonTokenCommand,\n\tIncreaseOtpAttemptCommand,\n\tInvalidateTokenCommand,\n\tMarkEmailVerifiedCommand,\n} from '../commands/index.js'\nimport { getPreferredProject } from './helpers/getPreferredProject.js'\nimport { isPasswordlessEnabled } from './helpers/isPasswordlessEnabled.js'\nimport { ProjectManager } from './ProjectManager.js'\nimport { PermissionContext } from '../authorization/index.js'\nimport { PersonTokenQuery } from '../queries/personToken/PersonTokenQuery.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { OtpManager } from './OtpManager.js'\nimport { BackupCodeManager } from './BackupCodeManager.js'\nimport { PersonToken } from '../type/index.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { intervalToSeconds } from '../utils/interval.js'\nimport { NextMailAttemptQuery } from '../queries/authLog/NextMailAttemptQuery.js'\n\nclass PasswordlessSignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly mailer: UserMailer,\n\t\tprivate readonly projectManager: ProjectManager,\n\t\tprivate readonly otpManager: OtpManager,\n\t\tprivate readonly backupCodeManager: BackupCodeManager,\n\t) {}\n\n\tasync initSignInPasswordless({ db, permissionContext, mailVariant, mailProject, email }: {\n\t\tdb: DatabaseContext\n\t\tpermissionContext: PermissionContext\n\t\temail: string\n\t\tmailVariant?: string\n\t\tmailProject?: string\n\t}): Promise<PasswordlessSignInManager.InitSignInPasswordlessResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.InitSignInPasswordlessResponse> => {\n\t\t\tconst configuration = await db.queryHandler.fetch(new ConfigurationQuery(db.providers))\n\t\t\tif (configuration.passwordless.enabled === 'never') {\n\t\t\t\treturn new ResponseError('PASSWORDLESS_DISABLED', 'Passwordless sign-in is disabled', {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tconst person = await db.queryHandler.fetch(PersonQuery.byEmail(email))\n\t\t\tif (!person) {\n\t\t\t\treturn new ResponseError('PERSON_NOT_FOUND', `Person ${email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tif (!isPasswordlessEnabled(configuration.passwordless.enabled, person.passwordless_enabled)) {\n\t\t\t\treturn new ResponseError('PASSWORDLESS_DISABLED', 'Passwordless sign-in is disabled for this person', {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Per-email exponential backoff on outbound mails — bound the spam\n\t\t\t// pressure on a real user even if attackers spray the endpoint.\n\t\t\t// Reuses login_* backoff config against person_auth_log.\n\t\t\tconst nextAllowed = await db.queryHandler.fetch(\n\t\t\t\tnew NextMailAttemptQuery(email, 'passwordless_login_init', 'passwordless_login'),\n\t\t\t)\n\t\t\tif (nextAllowed > new Date()) {\n\t\t\t\tconst retryAfterSeconds = Math.max(1, Math.ceil((nextAllowed.getTime() - Date.now()) / 1000))\n\t\t\t\treturn new ResponseError(\n\t\t\t\t\t'RATE_LIMIT_EXCEEDED',\n\t\t\t\t\t`Too many passwordless sign-in requests for this email. Retry after ${retryAfterSeconds}s.`,\n\t\t\t\t\t{\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tconst createTokenCommand = CreatePersonTokenCommand.createPasswordlessRequest(\n\t\t\t\tperson.id,\n\t\t\t\tintervalToSeconds(configuration.passwordless.expiration) / 60,\n\t\t\t)\n\t\t\tconst result = await db.commandBus.execute(createTokenCommand)\n\n\t\t\tconst url = this.formatUrl(configuration.passwordless.url ?? null, result.token, result.id, email) ?? undefined\n\n\t\t\tconst projects = await this.projectManager.getProjectsByIdentity(db, person.identity_id, permissionContext)\n\t\t\tconst project = getPreferredProject(projects, mailProject ?? null)\n\n\t\t\tawait this.mailer.sendPasswordlessEmail(db, {\n\t\t\t\temail,\n\t\t\t\ttoken: result.token,\n\t\t\t\turl,\n\t\t\t\tproject: project?.name,\n\t\t\t\tprojectSlug: project?.slug,\n\t\t\t\trequestId: result.id,\n\t\t\t}, {\n\t\t\t\tvariant: mailVariant || '',\n\t\t\t\tprojectId: project?.id ?? null,\n\t\t\t})\n\n\t\t\treturn new ResponseOk({\n\t\t\t\trequestId: result.id,\n\t\t\t\texpiresAt: result.expiresAt,\n\t\t\t\tperson,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonInput: email,\n\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\ttokenId: result.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tprivate formatUrl(url: string | null, token: string, requestId: string, email: string): string | null {\n\t\tif (!url) {\n\t\t\treturn null\n\t\t}\n\t\tconst urlObj = new URL(url)\n\t\turlObj.searchParams.set('token', token)\n\t\turlObj.searchParams.set('request_id', requestId)\n\t\turlObj.searchParams.set('email', email)\n\t\treturn urlObj.toString()\n\t}\n\n\tasync signInPasswordless({ db, expiration, token, requestId, mfaOtp, backupCode, validationType, requestInfo, trustForwardedInfo }: {\n\t\tdb: DatabaseContext\n\t\tvalidationType: PersonToken.ValidationType\n\t\trequestId: string\n\t\ttoken: string\n\t\tmfaOtp?: string\n\t\tbackupCode?: string\n\t\texpiration?: number\n\t\trequestInfo?: ApiKeyRequestInfo\n\t\ttrustForwardedInfo?: boolean\n\t}): Promise<PasswordlessSignInManager.SignInPasswordlessResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.SignInPasswordlessResponse> => {\n\t\t\tconst tokenResult = await db.queryHandler.fetch(PersonTokenQuery.byId(requestId, 'passwordless'))\n\t\t\tconst tokenValidationResult = validateToken({\n\t\t\t\tentry: tokenResult,\n\t\t\t\ttoken,\n\t\t\t\tnow: db.providers.now(),\n\t\t\t\tvalidationType,\n\t\t\t})\n\t\t\tif (!tokenValidationResult.ok) {\n\t\t\t\tif (tokenValidationResult.error === 'TOKEN_INVALID' && validationType === 'otp') {\n\t\t\t\t\tawait db.commandBus.execute(new IncreaseOtpAttemptCommand(tokenResult!.id))\n\t\t\t\t}\n\t\t\t\treturn new ResponseError(tokenValidationResult.error, tokenValidationResult.errorMessage, {\n\t\t\t\t\t...tokenValidationResult.metadata ?? {},\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tif (!tokenResult) {\n\t\t\t\tthrow new ImplementationException()\n\t\t\t}\n\t\t\tconst personRow = await db.queryHandler.fetch(PersonQuery.byId(tokenValidationResult.result.person_id))\n\t\t\tif (!personRow) {\n\t\t\t\tthrow new ImplementationException()\n\t\t\t}\n\t\t\tif (personRow.disabled_at !== null) {\n\t\t\t\treturn new ResponseError('PERSON_DISABLED', `Person is disabled`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tlet usedBackupCode = false\n\t\t\tif (personRow.otp_secret && personRow.otp_activated_at) {\n\t\t\t\tif (mfaOtp) {\n\t\t\t\t\tif (!await this.otpManager.verifyOtp(db, personRow, mfaOtp)) {\n\t\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else if (backupCode) {\n\t\t\t\t\tif (!await this.backupCodeManager.verifyAndConsume(db, personRow, backupCode)) {\n\t\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tusedBackupCode = true\n\t\t\t\t} else {\n\t\t\t\t\treturn new ResponseError('OTP_REQUIRED', `2FA is enabled. OTP token is required`, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\t// NOTE: passwordless sign-in does NOT enforce mandatory-MFA enrollment.\n\t\t\t// A person with no MFA factor is let through even when an auth_policy would\n\t\t\t// require MFA — enroll-via-sign-in is password-only, and passwordless email\n\t\t\t// access is itself a possession factor. An existing factor (TOTP/backup\n\t\t\t// code, handled above) is still verified. Documented limitation (A06).\n\n\t\t\tawait db.commandBus.execute(new InvalidateTokenCommand(tokenValidationResult.result.id))\n\t\t\t// Receiving and clicking the magic link proves ownership of the address,\n\t\t\t// so a successful passwordless sign-in also satisfies the e-mail\n\t\t\t// verification requirement. Without this, a required-but-unverified\n\t\t\t// account could obtain a session here while being blocked from password\n\t\t\t// sign-in (SignInManager) — an inconsistent, exploitable gap.\n\t\t\tconst emailVerifiedNow = personRow.email_verified_at === null\n\t\t\tif (emailVerifiedNow) {\n\t\t\t\tawait db.commandBus.execute(new MarkEmailVerifiedCommand(personRow.id))\n\t\t\t}\n\t\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(db, personRow.identity_id, expiration, requestInfo, trustForwardedInfo)\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: personRow,\n\t\t\t\ttoken: sessionToken,\n\t\t\t\tusedBackupCode,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t// Record that this sign-in also satisfied e-mail verification, so\n\t\t\t\t\t// the audit trail shows when/how the address became verified.\n\t\t\t\t\teventData: emailVerifiedNow ? { emailVerified: true } : undefined,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tasync activatePasswordlessOtp({ db, token, requestId, otpHash }: {\n\t\tdb: DatabaseContext\n\t\trequestId: string\n\t\ttoken: string\n\t\totpHash: string\n\t}): Promise<PasswordlessSignInManager.ActivatePasswordlessOtpResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.ActivatePasswordlessOtpResponse> => {\n\t\t\tconst tokenResult = await db.queryHandler.fetch(PersonTokenQuery.byId(requestId, 'passwordless'))\n\t\t\tconst tokenValidationResult = validateToken({\n\t\t\t\tentry: tokenResult,\n\t\t\t\ttoken,\n\t\t\t\tnow: db.providers.now(),\n\t\t\t\tvalidationType: 'token',\n\t\t\t})\n\t\t\tif (!tokenValidationResult.ok) {\n\t\t\t\treturn new ResponseError(tokenValidationResult.error, tokenValidationResult.errorMessage, {\n\t\t\t\t\t...tokenValidationResult.metadata ?? {},\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tawait db.commandBus.execute(new ActivateOtpCommand(tokenValidationResult.result.id, otpHash))\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: tokenResult?.person_id,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n}\n\nnamespace PasswordlessSignInManager {\n\texport type InitSignInPasswordlessResponse = Response<\n\t\tInitSignInPasswordlessResult & {\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t},\n\t\tInitSignInPasswordlessErrorCode,\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}\n\t>\n\n\tinterface SignInPasswordlessResult {\n\t\treadonly person: PersonRow\n\t\treadonly token: string\n\t\t/** True when MFA was satisfied by consuming a backup code (instead of a TOTP token). */\n\t\treadonly usedBackupCode?: boolean\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n\texport type SignInPasswordlessResponse = Response<SignInPasswordlessResult, SignInPasswordlessErrorCode, {\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n\n\texport type ActivatePasswordlessOtpResponse = Response<\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t},\n\t\tActivatePasswordlessOtpErrorCode,\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}\n\t>\n}\n\nexport { PasswordlessSignInManager }\n"],"names":["db"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"PasswordlessSignInManager.js","sources":["../../../../../../packages/engine-tenant-api/src/model/service/PasswordlessSignInManager.ts"],"sourcesContent":["import { ApiKeyManager } from './apiKey/index.js'\nimport { ConfigurationQuery, LocalAuthDisablingIdpsQuery, PersonQuery, PersonRow } from '../queries/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport {\n\tActivatePasswordlessOtpErrorCode,\n\tInitSignInPasswordlessErrorCode,\n\tInitSignInPasswordlessResult,\n\tSignInPasswordlessErrorCode,\n} from '../../schema/index.js'\nimport { DatabaseContext, validateToken } from '../utils/index.js'\nimport { UserMailer } from '../mailing/index.js'\nimport {\n\tActivateOtpCommand,\n\tApiKeyRequestInfo,\n\tCreatePersonTokenCommand,\n\tIncreaseOtpAttemptCommand,\n\tInvalidateTokenCommand,\n\tMarkEmailVerifiedCommand,\n} from '../commands/index.js'\nimport { getPreferredProject } from './helpers/getPreferredProject.js'\nimport { isPasswordlessEnabled } from './helpers/isPasswordlessEnabled.js'\nimport { ProjectManager } from './ProjectManager.js'\nimport { PermissionContext } from '../authorization/index.js'\nimport { PersonTokenQuery } from '../queries/personToken/PersonTokenQuery.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { OtpManager } from './OtpManager.js'\nimport { BackupCodeManager } from './BackupCodeManager.js'\nimport { PersonToken } from '../type/index.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { intervalToSeconds } from '../utils/interval.js'\nimport { NextMailAttemptQuery } from '../queries/authLog/NextMailAttemptQuery.js'\n\nclass PasswordlessSignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly mailer: UserMailer,\n\t\tprivate readonly projectManager: ProjectManager,\n\t\tprivate readonly otpManager: OtpManager,\n\t\tprivate readonly backupCodeManager: BackupCodeManager,\n\t) {}\n\n\tasync initSignInPasswordless({ db, permissionContext, mailVariant, mailProject, email }: {\n\t\tdb: DatabaseContext\n\t\tpermissionContext: PermissionContext\n\t\temail: string\n\t\tmailVariant?: string\n\t\tmailProject?: string\n\t}): Promise<PasswordlessSignInManager.InitSignInPasswordlessResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.InitSignInPasswordlessResponse> => {\n\t\t\tconst configuration = await db.queryHandler.fetch(new ConfigurationQuery(db.providers))\n\t\t\tif (configuration.passwordless.enabled === 'never') {\n\t\t\t\treturn new ResponseError('PASSWORDLESS_DISABLED', 'Passwordless sign-in is disabled', {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tconst person = await db.queryHandler.fetch(PersonQuery.byEmail(email))\n\t\t\tif (!person) {\n\t\t\t\treturn new ResponseError('PERSON_NOT_FOUND', `Person ${email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst exclusiveIdps = await db.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(person.id))\n\t\t\tif (exclusiveIdps.length > 0) {\n\t\t\t\treturn new ResponseError('IDP_REQUIRED', `Sign in using ${exclusiveIdps.join(' or ')}`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (!isPasswordlessEnabled(configuration.passwordless.enabled, person.passwordless_enabled)) {\n\t\t\t\treturn new ResponseError('PASSWORDLESS_DISABLED', 'Passwordless sign-in is disabled for this person', {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Per-email exponential backoff on outbound mails — bound the spam\n\t\t\t// pressure on a real user even if attackers spray the endpoint.\n\t\t\t// Reuses login_* backoff config against person_auth_log.\n\t\t\tconst nextAllowed = await db.queryHandler.fetch(\n\t\t\t\tnew NextMailAttemptQuery(email, 'passwordless_login_init', 'passwordless_login'),\n\t\t\t)\n\t\t\tif (nextAllowed > new Date()) {\n\t\t\t\tconst retryAfterSeconds = Math.max(1, Math.ceil((nextAllowed.getTime() - Date.now()) / 1000))\n\t\t\t\treturn new ResponseError(\n\t\t\t\t\t'RATE_LIMIT_EXCEEDED',\n\t\t\t\t\t`Too many passwordless sign-in requests for this email. Retry after ${retryAfterSeconds}s.`,\n\t\t\t\t\t{\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonInput: email,\n\t\t\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tconst createTokenCommand = CreatePersonTokenCommand.createPasswordlessRequest(\n\t\t\t\tperson.id,\n\t\t\t\tintervalToSeconds(configuration.passwordless.expiration) / 60,\n\t\t\t)\n\t\t\tconst result = await db.commandBus.execute(createTokenCommand)\n\n\t\t\tconst url = this.formatUrl(configuration.passwordless.url ?? null, result.token, result.id, email) ?? undefined\n\n\t\t\tconst projects = await this.projectManager.getProjectsByIdentity(db, person.identity_id, permissionContext)\n\t\t\tconst project = getPreferredProject(projects, mailProject ?? null)\n\n\t\t\tawait this.mailer.sendPasswordlessEmail(db, {\n\t\t\t\temail,\n\t\t\t\ttoken: result.token,\n\t\t\t\turl,\n\t\t\t\tproject: project?.name,\n\t\t\t\tprojectSlug: project?.slug,\n\t\t\t\trequestId: result.id,\n\t\t\t}, {\n\t\t\t\tvariant: mailVariant || '',\n\t\t\t\tprojectId: project?.id ?? null,\n\t\t\t})\n\n\t\t\treturn new ResponseOk({\n\t\t\t\trequestId: result.id,\n\t\t\t\texpiresAt: result.expiresAt,\n\t\t\t\tperson,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonInput: email,\n\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\ttokenId: result.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tprivate formatUrl(url: string | null, token: string, requestId: string, email: string): string | null {\n\t\tif (!url) {\n\t\t\treturn null\n\t\t}\n\t\tconst urlObj = new URL(url)\n\t\turlObj.searchParams.set('token', token)\n\t\turlObj.searchParams.set('request_id', requestId)\n\t\turlObj.searchParams.set('email', email)\n\t\treturn urlObj.toString()\n\t}\n\n\tasync signInPasswordless({ db, expiration, token, requestId, mfaOtp, backupCode, validationType, requestInfo, trustForwardedInfo }: {\n\t\tdb: DatabaseContext\n\t\tvalidationType: PersonToken.ValidationType\n\t\trequestId: string\n\t\ttoken: string\n\t\tmfaOtp?: string\n\t\tbackupCode?: string\n\t\texpiration?: number\n\t\trequestInfo?: ApiKeyRequestInfo\n\t\ttrustForwardedInfo?: boolean\n\t}): Promise<PasswordlessSignInManager.SignInPasswordlessResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.SignInPasswordlessResponse> => {\n\t\t\tconst tokenResult = await db.queryHandler.fetch(PersonTokenQuery.byId(requestId, 'passwordless'))\n\t\t\tconst tokenValidationResult = validateToken({\n\t\t\t\tentry: tokenResult,\n\t\t\t\ttoken,\n\t\t\t\tnow: db.providers.now(),\n\t\t\t\tvalidationType,\n\t\t\t})\n\t\t\tif (!tokenValidationResult.ok) {\n\t\t\t\tif (tokenValidationResult.error === 'TOKEN_INVALID' && validationType === 'otp') {\n\t\t\t\t\tawait db.commandBus.execute(new IncreaseOtpAttemptCommand(tokenResult!.id))\n\t\t\t\t}\n\t\t\t\treturn new ResponseError(tokenValidationResult.error, tokenValidationResult.errorMessage, {\n\t\t\t\t\t...tokenValidationResult.metadata ?? {},\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tif (!tokenResult) {\n\t\t\t\tthrow new ImplementationException()\n\t\t\t}\n\t\t\tconst personRow = await db.queryHandler.fetch(PersonQuery.byId(tokenValidationResult.result.person_id))\n\t\t\tif (!personRow) {\n\t\t\t\tthrow new ImplementationException()\n\t\t\t}\n\t\t\tif (personRow.disabled_at !== null) {\n\t\t\t\treturn new ResponseError('PERSON_DISABLED', `Person is disabled`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Re-checked here and not only at init: a link created while this magic link\n\t\t\t// was in flight would otherwise still mint a locally authenticated session.\n\t\t\tconst exclusiveIdps = await db.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(personRow.id))\n\t\t\tif (exclusiveIdps.length > 0) {\n\t\t\t\treturn new ResponseError('IDP_REQUIRED', `Sign in using ${exclusiveIdps.join(' or ')}`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tlet usedBackupCode = false\n\t\t\tif (personRow.otp_secret && personRow.otp_activated_at) {\n\t\t\t\tif (mfaOtp) {\n\t\t\t\t\tif (!await this.otpManager.verifyOtp(db, personRow, mfaOtp)) {\n\t\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else if (backupCode) {\n\t\t\t\t\tif (!await this.backupCodeManager.verifyAndConsume(db, personRow, backupCode)) {\n\t\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tusedBackupCode = true\n\t\t\t\t} else {\n\t\t\t\t\treturn new ResponseError('OTP_REQUIRED', `2FA is enabled. OTP token is required`, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\t// NOTE: passwordless sign-in does NOT enforce mandatory-MFA enrollment.\n\t\t\t// A person with no MFA factor is let through even when an auth_policy would\n\t\t\t// require MFA — enroll-via-sign-in is password-only, and passwordless email\n\t\t\t// access is itself a possession factor. An existing factor (TOTP/backup\n\t\t\t// code, handled above) is still verified. Documented limitation (A06).\n\n\t\t\tawait db.commandBus.execute(new InvalidateTokenCommand(tokenValidationResult.result.id))\n\t\t\t// Receiving and clicking the magic link proves ownership of the address,\n\t\t\t// so a successful passwordless sign-in also satisfies the e-mail\n\t\t\t// verification requirement. Without this, a required-but-unverified\n\t\t\t// account could obtain a session here while being blocked from password\n\t\t\t// sign-in (SignInManager) — an inconsistent, exploitable gap.\n\t\t\tconst emailVerifiedNow = personRow.email_verified_at === null\n\t\t\tif (emailVerifiedNow) {\n\t\t\t\tawait db.commandBus.execute(new MarkEmailVerifiedCommand(personRow.id))\n\t\t\t}\n\t\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(db, personRow.identity_id, expiration, requestInfo, trustForwardedInfo)\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: personRow,\n\t\t\t\ttoken: sessionToken,\n\t\t\t\tusedBackupCode,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t// Record that this sign-in also satisfied e-mail verification, so\n\t\t\t\t\t// the audit trail shows when/how the address became verified.\n\t\t\t\t\teventData: emailVerifiedNow ? { emailVerified: true } : undefined,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tasync activatePasswordlessOtp({ db, token, requestId, otpHash }: {\n\t\tdb: DatabaseContext\n\t\trequestId: string\n\t\ttoken: string\n\t\totpHash: string\n\t}): Promise<PasswordlessSignInManager.ActivatePasswordlessOtpResponse> {\n\t\treturn db.transaction(async (db): Promise<PasswordlessSignInManager.ActivatePasswordlessOtpResponse> => {\n\t\t\tconst tokenResult = await db.queryHandler.fetch(PersonTokenQuery.byId(requestId, 'passwordless'))\n\t\t\tconst tokenValidationResult = validateToken({\n\t\t\t\tentry: tokenResult,\n\t\t\t\ttoken,\n\t\t\t\tnow: db.providers.now(),\n\t\t\t\tvalidationType: 'token',\n\t\t\t})\n\t\t\tif (!tokenValidationResult.ok) {\n\t\t\t\treturn new ResponseError(tokenValidationResult.error, tokenValidationResult.errorMessage, {\n\t\t\t\t\t...tokenValidationResult.metadata ?? {},\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tawait db.commandBus.execute(new ActivateOtpCommand(tokenValidationResult.result.id, otpHash))\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: tokenResult?.person_id,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: tokenResult?.person_id,\n\t\t\t\t\ttokenId: tokenResult?.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n}\n\nnamespace PasswordlessSignInManager {\n\texport type InitSignInPasswordlessResponse = Response<\n\t\tInitSignInPasswordlessResult & {\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t},\n\t\tInitSignInPasswordlessErrorCode,\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}\n\t>\n\n\tinterface SignInPasswordlessResult {\n\t\treadonly person: PersonRow\n\t\treadonly token: string\n\t\t/** True when MFA was satisfied by consuming a backup code (instead of a TOTP token). */\n\t\treadonly usedBackupCode?: boolean\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n\texport type SignInPasswordlessResponse = Response<SignInPasswordlessResult, SignInPasswordlessErrorCode, {\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n\n\texport type ActivatePasswordlessOtpResponse = Response<\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t},\n\t\tActivatePasswordlessOtpErrorCode,\n\t\t{\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}\n\t>\n}\n\nexport { PasswordlessSignInManager }\n"],"names":["db"],"mappings":"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"}
|
|
@@ -6,6 +6,7 @@ require("../type/ApiKey.cjs");
|
|
|
6
6
|
const ConfigurationQuery = require("../queries/configuration/ConfigurationQuery.cjs");
|
|
7
7
|
const exceptions = require("../../exceptions.cjs");
|
|
8
8
|
const PersonQuery = require("../queries/person/PersonQuery.cjs");
|
|
9
|
+
const LocalAuthDisablingIdpsQuery = require("../queries/idp/LocalAuthDisablingIdpsQuery.cjs");
|
|
9
10
|
require("../authorization/PermissionActions.cjs");
|
|
10
11
|
require("@contember/authorization");
|
|
11
12
|
require("../authorization/PermissionContext.cjs");
|
|
@@ -42,6 +43,10 @@ class SignInManager {
|
|
|
42
43
|
personId: person.id
|
|
43
44
|
})
|
|
44
45
|
};
|
|
46
|
+
const exclusiveIdps = await dbContext.queryHandler.fetch(new LocalAuthDisablingIdpsQuery.LocalAuthDisablingIdpsQuery(person.id));
|
|
47
|
+
if (exclusiveIdps.length > 0) {
|
|
48
|
+
return new Response.ResponseError("IDP_REQUIRED", `Sign in using ${exclusiveIdps.join(" or ")}`, authLogData);
|
|
49
|
+
}
|
|
45
50
|
if (!person.password_hash) {
|
|
46
51
|
return new Response.ResponseError("NO_PASSWORD_SET", `No password set`, authLogData);
|
|
47
52
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SignInManager.cjs","sources":["../../../../../../packages/engine-tenant-api/src/model/service/SignInManager.ts"],"sourcesContent":["import { CreateSessionTokenErrorCode, MfaEnrollment, SignInErrorCode } from '../../schema/index.js'\nimport { ApiKeyManager, AuthPolicyResolver, BackupCodeManager, EmailOtpManager, LoginRiskAnalyzer, OtpManager } from '../service/index.js'\nimport { ConfigurationQuery, PersonQuery, PersonRow, PersonUniqueIdentifier } from '../queries/index.js'\nimport { Config } from '../type/Config.js'\nimport { Providers } from '../providers.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { UserMailer } from '../mailing/index.js'\nimport { ApiKeyRequestInfo, SetMfaGraceUntilCommand } from '../commands/index.js'\nimport { intervalToSeconds } from '../utils/interval.js'\n\nclass SignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly providers: Providers,\n\t\tprivate readonly otpManager: OtpManager,\n\t\tprivate readonly backupCodeManager: BackupCodeManager,\n\t\tprivate readonly emailOtpManager: EmailOtpManager,\n\t\tprivate readonly authPolicyResolver: AuthPolicyResolver,\n\t\tprivate readonly loginRiskAnalyzer: LoginRiskAnalyzer,\n\t\tprivate readonly userMailer: UserMailer,\n\t) {}\n\n\tasync signIn(\n\t\tdbContext: DatabaseContext,\n\t\temail: string,\n\t\tpassword: string,\n\t\texpiration?: number,\n\t\totpCode?: string,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t\tbackupCode?: string,\n\t\tconfig?: Config,\n\t\tgeoCountry?: string,\n\t): Promise<SignInResponse> {\n\t\tconst person = await dbContext.queryHandler.fetch(PersonQuery.byEmail(email))\n\n\t\tif (person === null) {\n\t\t\treturn new ResponseError('UNKNOWN_EMAIL', `Person with email ${email} not found`, {\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonInput: email,\n\t\t\t\t}),\n\t\t\t})\n\t\t}\n\n\t\tconst authLogData = {\n\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\tpersonInput: email,\n\t\t\t\tpersonId: person.id,\n\t\t\t}),\n\t\t}\n\t\tif (!person.password_hash) {\n\t\t\treturn new ResponseError('NO_PASSWORD_SET', `No password set`, authLogData)\n\t\t}\n\n\t\tif (person.disabled_at !== null) {\n\t\t\treturn new ResponseError('PERSON_DISABLED', `Person is disabled`, authLogData)\n\t\t}\n\n\t\tconst passwordValid = await this.providers.bcryptCompare(password, person.password_hash)\n\n\t\tif (!passwordValid) {\n\t\t\treturn new ResponseError('INVALID_PASSWORD', `Password does not match`, authLogData)\n\t\t}\n\n\t\t// Block sign-in for accounts that were created while email verification\n\t\t// was required but never proved ownership. Accounts predating the\n\t\t// requirement carry email_verification_required = false and pass through.\n\t\tif (person.email_verification_required && person.email_verified_at === null) {\n\t\t\treturn new ResponseError('EMAIL_NOT_VERIFIED', `E-mail address has not been verified`, authLogData)\n\t\t}\n\n\t\tlet usedBackupCode = false\n\t\t// Whether a second factor was actively proven in THIS request. Anomaly\n\t\t// step-up (A03) reuses this: a login that already cleared a second factor\n\t\t// is not asked to step up again.\n\t\tlet mfaSatisfiedThisRequest = false\n\t\tconst hasActiveTotp = Boolean(person.otp_secret && person.otp_activated_at)\n\t\tif (hasActiveTotp) {\n\t\t\t// Active TOTP takes precedence (A07 path, unchanged).\n\t\t\tif (otpCode) {\n\t\t\t\tif (!await this.otpManager.verifyOtp(dbContext, person, otpCode)) {\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t\t}\n\t\t\t\tmfaSatisfiedThisRequest = true\n\t\t\t} else if (backupCode) {\n\t\t\t\tif (!await this.backupCodeManager.verifyAndConsume(dbContext, person, backupCode)) {\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t\t}\n\t\t\t\tusedBackupCode = true\n\t\t\t\tmfaSatisfiedThisRequest = true\n\t\t\t} else {\n\t\t\t\treturn new ResponseError('OTP_REQUIRED', `2FA is enabled. OTP token is required`, authLogData)\n\t\t\t}\n\t\t} else if (person.email_otp_enabled) {\n\t\t\t// No active TOTP, but email OTP is enabled (A05).\n\t\t\tif (otpCode) {\n\t\t\t\tif (!await this.emailOtpManager.verifyAndConsume(dbContext, person, otpCode)) {\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t\t}\n\t\t\t\tmfaSatisfiedThisRequest = true\n\t\t\t} else if (backupCode) {\n\t\t\t\tif (!await this.backupCodeManager.verifyAndConsume(dbContext, person, backupCode)) {\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t\t}\n\t\t\t\tusedBackupCode = true\n\t\t\t\tmfaSatisfiedThisRequest = true\n\t\t\t} else {\n\t\t\t\t// Dispatch a fresh code and ask the client to retry with otpToken. Reuses\n\t\t\t\t// the existing OTP_REQUIRED contract so clients that already handle it work.\n\t\t\t\t// The send is rate-limited per person; when throttled nothing is emailed\n\t\t\t\t// (emailOtpSent: false) but a previously emailed code stays valid.\n\t\t\t\t// Independent fetch (not the passed-in `config`): the email-OTP send is\n\t\t\t\t// rate-limited by the live config and this branch never reaches the\n\t\t\t\t// anomaly analyzer, so query shape stays as before A03.\n\t\t\t\tconst emailOtpConfig = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\t\t\t\tconst decision = await this.emailOtpManager.sendCode(dbContext, person, emailOtpConfig)\n\t\t\t\treturn new ResponseError('OTP_REQUIRED', `2FA is enabled. OTP token is required`, {\n\t\t\t\t\temailOtpSent: decision.ok,\n\t\t\t\t\t...authLogData,\n\t\t\t\t})\n\t\t\t}\n\t\t} else {\n\t\t\t// No active factor at all (A06). Enforce MFA if a matching auth_policy\n\t\t\t// requires it. With no policy rows configured this resolves to\n\t\t\t// mfaRequired=false and sign-in proceeds exactly as before.\n\t\t\tconst enforcement = await this.enforceMfaEnrollment(dbContext, person, otpCode, authLogData)\n\t\t\tif (enforcement !== null) {\n\t\t\t\tif ('error' in enforcement) {\n\t\t\t\t\treturn enforcement\n\t\t\t\t}\n\t\t\t\t// Enrollment completed in this request (pending TOTP promoted to active),\n\t\t\t\t// so MFA is definitively satisfied — anomaly step-up cannot apply.\n\t\t\t\tconst riskHandled = await this.applyRiskPolicy(dbContext, person, {\n\t\t\t\t\tconfig,\n\t\t\t\t\tgeoCountry,\n\t\t\t\t\trequestInfo,\n\t\t\t\t\totpCode,\n\t\t\t\t\tmfaSatisfiedThisRequest: true,\n\t\t\t\t\tauthLogData,\n\t\t\t\t})\n\t\t\t\tif (riskHandled !== null && 'error' in riskHandled) {\n\t\t\t\t\treturn riskHandled\n\t\t\t\t}\n\t\t\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(\n\t\t\t\t\tdbContext,\n\t\t\t\t\tperson.identity_id,\n\t\t\t\t\texpiration,\n\t\t\t\t\trequestInfo,\n\t\t\t\t\ttrustForwardedInfo,\n\t\t\t\t)\n\t\t\t\treturn new ResponseOk({\n\t\t\t\t\tperson,\n\t\t\t\t\ttoken: sessionToken,\n\t\t\t\t\tbackupCodes: enforcement.backupCodes,\n\t\t\t\t\tunusualLoginDetected: riskHandled?.unusualLoginDetected || undefined,\n\t\t\t\t\trisk: riskHandled?.risk,\n\t\t\t\t\t...authLogData,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tconst riskHandled = await this.applyRiskPolicy(dbContext, person, {\n\t\t\tconfig,\n\t\t\tgeoCountry,\n\t\t\trequestInfo,\n\t\t\totpCode,\n\t\t\tmfaSatisfiedThisRequest,\n\t\t\tauthLogData,\n\t\t})\n\t\tif (riskHandled !== null && 'error' in riskHandled) {\n\t\t\treturn riskHandled\n\t\t}\n\n\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(dbContext, person.identity_id, expiration, requestInfo, trustForwardedInfo)\n\n\t\treturn new ResponseOk({\n\t\t\tperson,\n\t\t\ttoken: sessionToken,\n\t\t\tusedBackupCode,\n\t\t\tunusualLoginDetected: riskHandled?.unusualLoginDetected || undefined,\n\t\t\trisk: riskHandled?.risk,\n\t\t\t...authLogData,\n\t\t})\n\t}\n\n\t/**\n\t * A03 — evaluate the sign-in risk score and act per policy. Returns:\n\t * - `null` when anomaly detection is off or no `config` was supplied (the\n\t * default): no DB work, sign-in proceeds unchanged.\n\t * - `{ unusualLoginDetected: true }` when an informational UNUSUAL_LOGIN email\n\t * was sent (best-effort) but the session may still be issued.\n\t * - a `ResponseError('OTP_REQUIRED')` when step-up is required and no code was\n\t * supplied — reuses the email-OTP step-up channel (dispatches a code).\n\t * - a `ResponseError('INVALID_OTP_TOKEN')` when a step-up code was supplied but\n\t * did not verify. A valid code consumes the token and the session is issued.\n\t * - `{ unusualLoginDetected: false }` when the score was below every threshold.\n\t *\n\t * The audit entries (`unusual_login_detected`, `step_up_required`) are emitted\n\t * by the resolver from the response metadata, matching the project convention.\n\t */\n\tprivate async applyRiskPolicy(\n\t\tdbContext: DatabaseContext,\n\t\tperson: PersonRow,\n\t\targs: {\n\t\t\tconfig?: Config\n\t\t\tgeoCountry?: string\n\t\t\trequestInfo?: ApiKeyRequestInfo\n\t\t\totpCode?: string\n\t\t\tmfaSatisfiedThisRequest: boolean\n\t\t\tauthLogData: { [AuthLogService.Key]: AuthLogService.Bag }\n\t\t},\n\t): Promise<RiskPolicyOutcome> {\n\t\tconst { config, geoCountry, requestInfo, otpCode, mfaSatisfiedThisRequest, authLogData } = args\n\t\tconst policy = config?.login.anomalyDetection\n\t\tif (!config || !policy?.enabled) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst assessment = await this.loginRiskAnalyzer.analyze(dbContext, person.id, {\n\t\t\tgeoCountry: geoCountry ?? null,\n\t\t\tdeviceFingerprint: this.loginRiskAnalyzer.fingerprint(requestInfo?.userAgent),\n\t\t\tip: requestInfo?.ip ?? null,\n\t\t}, policy)\n\n\t\tif (assessment.action === 'allow') {\n\t\t\treturn { unusualLoginDetected: false }\n\t\t}\n\n\t\tconst riskMeta = { score: assessment.score, reasons: assessment.reasons }\n\n\t\tif (assessment.action === 'stepUp' && !mfaSatisfiedThisRequest) {\n\t\t\t// Reuse the existing email-OTP step-up channel. This is independent of\n\t\t\t// `person.email_otp_enabled`, which gates email OTP only as the *primary*\n\t\t\t// second factor (A05); the step-up challenge stands on its own, so a person\n\t\t\t// with no standing MFA can still complete it. Without consuming the code\n\t\t\t// here, such a person would never satisfy the step-up and would loop on\n\t\t\t// OTP_REQUIRED forever (the score is unchanged on every retry).\n\t\t\tif (otpCode) {\n\t\t\t\t// Retry leg: the client resent the emailed code as otpToken. Mirror the\n\t\t\t\t// A05 contract — an invalid code is rejected (not re-sent); the per-code\n\t\t\t\t// 3-attempt cap still applies, and a fresh code is issued only when the\n\t\t\t\t// client retries without a code.\n\t\t\t\tif (!await this.emailOtpManager.verifyAndConsume(dbContext, person, otpCode)) {\n\t\t\t\t\t// A failed *forced* second factor is itself an anomaly signal — carry the\n\t\t\t\t\t// step-up/risk metadata so the resolver still emits unusual_login_detected\n\t\t\t\t\t// + step_up_required with the score/reasons, rather than logging it as an\n\t\t\t\t\t// ordinary mistyped-OTP login indistinguishable from a normal 2FA typo.\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\tstepUpRequired: true,\n\t\t\t\t\t\tunusualLoginDetected: true,\n\t\t\t\t\t\trisk: riskMeta,\n\t\t\t\t\t\t...authLogData,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\t// Step-up satisfied this request — fall through to notify + allow below.\n\t\t\t} else {\n\t\t\t\t// First leg: dispatch a code and ask the client to retry with otpToken.\n\t\t\t\t// Rate-limited per person; a previously emailed code stays valid when throttled.\n\t\t\t\tconst decision = await this.emailOtpManager.sendCode(dbContext, person, config)\n\t\t\t\treturn new ResponseError('OTP_REQUIRED', 'Additional verification is required for this sign-in', {\n\t\t\t\t\temailOtpSent: decision.ok,\n\t\t\t\t\tstepUpRequired: true,\n\t\t\t\t\tunusualLoginDetected: true,\n\t\t\t\t\trisk: riskMeta,\n\t\t\t\t\t...authLogData,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\t// action === 'email', or step-up satisfied (already this request, or just now\n\t\t// via the emailed code): notify the person of the unusual sign-in. A failed\n\t\t// email must never block sign-in.\n\t\tawait this.sendUnusualLoginEmail(dbContext, person, geoCountry ?? null, requestInfo?.ip ?? null)\n\t\treturn { unusualLoginDetected: true, risk: riskMeta }\n\t}\n\n\tprivate async sendUnusualLoginEmail(dbContext: DatabaseContext, person: PersonRow, geoCountry: string | null, ip: string | null): Promise<void> {\n\t\tif (!person.email) {\n\t\t\t// No address to notify (e.g. an IDP-only person) — nothing to send.\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\tawait this.userMailer.sendUnusualLoginEmail(dbContext, {\n\t\t\t\temail: person.email,\n\t\t\t\tgeoCountry,\n\t\t\t\tipAddress: ip,\n\t\t\t}, { projectId: null, variant: '' })\n\t\t} catch {\n\t\t\t// ignore — informational only\n\t\t}\n\t}\n\n\t/**\n\t * A06 enforcement for the no-active-factor case. Returns:\n\t * - `null` when MFA is not required, or grace currently allows sign-in\n\t * (caller proceeds to issue a session as normal). With zero auth_policy\n\t * rows this is always the outcome — no behavior change.\n\t * - a `ResponseError` (MFA_ENROLLMENT_REQUIRED with the pending secret to\n\t * enroll, or INVALID_OTP_TOKEN) when sign-in must be blocked.\n\t * - `{ ok: true, backupCodes }` when enrollment just completed (pending TOTP\n\t * promoted to active); caller issues the session.\n\t */\n\tprivate async enforceMfaEnrollment(\n\t\tdbContext: DatabaseContext,\n\t\tperson: PersonRow,\n\t\totpCode: string | undefined,\n\t\tauthLogData: { [AuthLogService.Key]: AuthLogService.Bag },\n\t): Promise<\n\t\t| null\n\t\t| ResponseError<SignInErrorCode, {\n\t\t\temailOtpSent?: boolean\n\t\t\tmfaEnrollmentRequired?: boolean\n\t\t\tmfaEnrollment?: MfaEnrollment\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}>\n\t\t| { backupCodes: string[] }\n\t> {\n\t\tconst policy = await this.authPolicyResolver.resolveForIdentity(dbContext, person.identity_id, person.roles)\n\t\tif (!policy.mfaRequired) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst now = this.providers.now()\n\n\t\t// Resolve the effective grace duration (seconds). A per-policy override\n\t\t// wins; otherwise we fall back to the global config default. Only fetch the\n\t\t// config when the override is absent, to keep query shapes minimal.\n\t\tlet graceSeconds: number\n\t\tif (policy.graceDuration !== null) {\n\t\t\tgraceSeconds = intervalToSeconds(policy.graceDuration)\n\t\t} else {\n\t\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\t\t\tgraceSeconds = intervalToSeconds(config.login.mfaGraceDuration)\n\t\t}\n\n\t\t// Grace handling. Default grace duration is 0 (immediate enforcement), so\n\t\t// by default neither branch below opens a window.\n\t\tif (person.mfa_grace_until !== null) {\n\t\t\tif (person.mfa_grace_until.getTime() > now.getTime()) {\n\t\t\t\t// Still inside an open grace window — allow sign-in.\n\t\t\t\treturn null\n\t\t\t}\n\t\t\t// Grace expired → fall through to enforcement.\n\t\t} else if (graceSeconds > 0) {\n\t\t\t// Open the grace window now and allow this sign-in.\n\t\t\tconst graceUntil = new Date(now.getTime() + graceSeconds * 1000)\n\t\t\tawait dbContext.commandBus.execute(new SetMfaGraceUntilCommand(person.id, graceUntil))\n\t\t\treturn null\n\t\t}\n\n\t\t// Enforce.\n\t\tif (otpCode && person.otp_pending_secret) {\n\t\t\t// Enrollment completion: verify against the pending secret, then promote.\n\t\t\tif (!await this.otpManager.verifyPendingOtp(person, otpCode)) {\n\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t}\n\t\t\tconst promoted = await this.otpManager.confirmOtp(dbContext, person)\n\t\t\tif (!promoted) {\n\t\t\t\t// Lost a concurrent enrollment-completion race: the pending secret was\n\t\t\t\t// already promoted by the other request (which issued the backup codes).\n\t\t\t\t// Don't regenerate here — that would invalidate the codes already\n\t\t\t\t// returned. TOTP is now active, so a retry signs in via the normal path.\n\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t}\n\t\t\tconst backupCodes = await this.backupCodeManager.generate(dbContext, person.id)\n\t\t\treturn { backupCodes }\n\t\t}\n\n\t\t// Provision a fresh pending TOTP secret and ask the client to enroll.\n\t\tconst otp = await this.otpManager.prepareOtp(dbContext, person, 'Contember')\n\t\tconst mfaEnrollment: MfaEnrollment = { otpUri: otp.uri, otpSecret: otp.secret }\n\t\treturn new ResponseError('MFA_ENROLLMENT_REQUIRED', 'MFA enrollment is required', {\n\t\t\tmfaEnrollmentRequired: true,\n\t\t\tmfaEnrollment,\n\t\t\t...authLogData,\n\t\t})\n\t}\n\n\tasync createSessionToken(\n\t\tdbContext: DatabaseContext,\n\t\tpersonIdentifier: PersonUniqueIdentifier,\n\t\texpiration?: number,\n\t\tverifier?: (person: PersonRow) => Promise<void>,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateSessionTokenResponse> {\n\t\tconst person = await dbContext.queryHandler.fetch(PersonQuery.byUniqueIdentifier(personIdentifier))\n\n\t\tif (person === null) {\n\t\t\tif (personIdentifier.type === 'email') {\n\t\t\t\treturn new ResponseError('UNKNOWN_EMAIL', `Person with email ${personIdentifier.email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: personIdentifier.email,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t} else if (personIdentifier.type === 'id') {\n\t\t\t\treturn new ResponseError('UNKNOWN_PERSON_ID', `Person with id ${personIdentifier.id} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: personIdentifier.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tthrow new ImplementationException()\n\t\t}\n\n\t\tconst inputValue = personIdentifier.type === 'email' ? personIdentifier.email : personIdentifier.id\n\t\tif (person.disabled_at !== null) {\n\t\t\treturn new ResponseError('PERSON_DISABLED', `Person with id ${person.id} is disabled`, {\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\tpersonInput: inputValue,\n\t\t\t\t}),\n\t\t\t})\n\t\t}\n\n\t\tawait verifier?.(person)\n\n\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(dbContext, person.identity_id, expiration, requestInfo, trustForwardedInfo)\n\t\treturn new ResponseOk({\n\t\t\tperson,\n\t\t\ttoken: sessionToken,\n\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\tpersonId: person.id,\n\t\t\t\tpersonInput: inputValue,\n\t\t\t}),\n\t\t})\n\t}\n}\n\n/** A03: greppable payload describing why a sign-in scored as unusual. */\nexport interface RiskMeta {\n\treadonly score: number\n\treadonly reasons: readonly string[]\n}\n\n/**\n * Result of {@link SignInManager.applyRiskPolicy}: `null` = feature off / no\n * config; a metadata object when the session may still be issued; a\n * `ResponseError` when step-up blocks the session.\n */\ntype RiskPolicyOutcome =\n\t| null\n\t| { unusualLoginDetected: boolean; risk?: RiskMeta }\n\t| ResponseError<SignInErrorCode, {\n\t\temailOtpSent?: boolean\n\t\tstepUpRequired?: boolean\n\t\tunusualLoginDetected?: boolean\n\t\trisk?: RiskMeta\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n\nexport interface SignInResult {\n\treadonly person: PersonRow\n\treadonly token: string\n\t/** True when MFA was satisfied by consuming a backup code (instead of a TOTP token). */\n\treadonly usedBackupCode?: boolean\n\t/** Set when sign-in completed an MFA enrollment (A06): the freshly issued backup codes, shown once. */\n\treadonly backupCodes?: string[]\n\t/** A03: true when this sign-in scored as unusual and an UNUSUAL_LOGIN email was sent. */\n\treadonly unusualLoginDetected?: boolean\n\t/** A03: the risk score + reasons, recorded in the `unusual_login_detected` audit `event_data`. */\n\treadonly risk?: RiskMeta\n\t[AuthLogService.Key]: AuthLogService.Bag\n}\n\nexport type SignInResponse = Response<SignInResult, SignInErrorCode, {\n\t/** Set on an OTP_REQUIRED error when an email-OTP code was just dispatched (A05). */\n\temailOtpSent?: boolean\n\t/** Set on a MFA_ENROLLMENT_REQUIRED error (A06): triggers the `mfa_enrollment_required` audit. */\n\tmfaEnrollmentRequired?: boolean\n\t/** Set on a MFA_ENROLLMENT_REQUIRED error (A06): the pending secret the client must enroll. */\n\tmfaEnrollment?: MfaEnrollment\n\t/** Set on an OTP_REQUIRED error raised by A03 anomaly step-up: triggers the `step_up_required` audit. */\n\tstepUpRequired?: boolean\n\t/** A03: true when the sign-in scored as unusual (triggers the `unusual_login_detected` audit). */\n\tunusualLoginDetected?: boolean\n\t/** A03: the risk score + reasons, recorded in the audit `event_data`. */\n\trisk?: RiskMeta\n\t[AuthLogService.Key]: AuthLogService.Bag\n}>\nexport type CreateSessionTokenResponse = Response<SignInResult, CreateSessionTokenErrorCode, {\n\t[AuthLogService.Key]: AuthLogService.Bag\n}>\n\nexport { SignInManager }\n"],"names":["PersonQuery","ResponseError","AuthLogService","ConfigurationQuery","riskHandled","sessionToken","ResponseOk","intervalToSeconds","SetMfaGraceUntilCommand","ImplementationException"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SignInManager.cjs","sources":["../../../../../../packages/engine-tenant-api/src/model/service/SignInManager.ts"],"sourcesContent":["import { CreateSessionTokenErrorCode, MfaEnrollment, SignInErrorCode } from '../../schema/index.js'\nimport { ApiKeyManager, AuthPolicyResolver, BackupCodeManager, EmailOtpManager, LoginRiskAnalyzer, OtpManager } from '../service/index.js'\nimport { ConfigurationQuery, LocalAuthDisablingIdpsQuery, PersonQuery, PersonRow, PersonUniqueIdentifier } from '../queries/index.js'\nimport { Config } from '../type/Config.js'\nimport { Providers } from '../providers.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { ImplementationException } from '../../exceptions.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { UserMailer } from '../mailing/index.js'\nimport { ApiKeyRequestInfo, SetMfaGraceUntilCommand } from '../commands/index.js'\nimport { intervalToSeconds } from '../utils/interval.js'\n\nclass SignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly providers: Providers,\n\t\tprivate readonly otpManager: OtpManager,\n\t\tprivate readonly backupCodeManager: BackupCodeManager,\n\t\tprivate readonly emailOtpManager: EmailOtpManager,\n\t\tprivate readonly authPolicyResolver: AuthPolicyResolver,\n\t\tprivate readonly loginRiskAnalyzer: LoginRiskAnalyzer,\n\t\tprivate readonly userMailer: UserMailer,\n\t) {}\n\n\tasync signIn(\n\t\tdbContext: DatabaseContext,\n\t\temail: string,\n\t\tpassword: string,\n\t\texpiration?: number,\n\t\totpCode?: string,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t\tbackupCode?: string,\n\t\tconfig?: Config,\n\t\tgeoCountry?: string,\n\t): Promise<SignInResponse> {\n\t\tconst person = await dbContext.queryHandler.fetch(PersonQuery.byEmail(email))\n\n\t\tif (person === null) {\n\t\t\treturn new ResponseError('UNKNOWN_EMAIL', `Person with email ${email} not found`, {\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonInput: email,\n\t\t\t\t}),\n\t\t\t})\n\t\t}\n\n\t\tconst authLogData = {\n\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\tpersonInput: email,\n\t\t\t\tpersonId: person.id,\n\t\t\t}),\n\t\t}\n\t\t// Refused before the password is even looked at: for a person federated\n\t\t// through a provider with `disableLocalAuthentication`, a password is not a\n\t\t// credential this tenant accepts, however it got set.\n\t\tconst exclusiveIdps = await dbContext.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(person.id))\n\t\tif (exclusiveIdps.length > 0) {\n\t\t\treturn new ResponseError('IDP_REQUIRED', `Sign in using ${exclusiveIdps.join(' or ')}`, authLogData)\n\t\t}\n\n\t\tif (!person.password_hash) {\n\t\t\treturn new ResponseError('NO_PASSWORD_SET', `No password set`, authLogData)\n\t\t}\n\n\t\tif (person.disabled_at !== null) {\n\t\t\treturn new ResponseError('PERSON_DISABLED', `Person is disabled`, authLogData)\n\t\t}\n\n\t\tconst passwordValid = await this.providers.bcryptCompare(password, person.password_hash)\n\n\t\tif (!passwordValid) {\n\t\t\treturn new ResponseError('INVALID_PASSWORD', `Password does not match`, authLogData)\n\t\t}\n\n\t\t// Block sign-in for accounts that were created while email verification\n\t\t// was required but never proved ownership. Accounts predating the\n\t\t// requirement carry email_verification_required = false and pass through.\n\t\tif (person.email_verification_required && person.email_verified_at === null) {\n\t\t\treturn new ResponseError('EMAIL_NOT_VERIFIED', `E-mail address has not been verified`, authLogData)\n\t\t}\n\n\t\tlet usedBackupCode = false\n\t\t// Whether a second factor was actively proven in THIS request. Anomaly\n\t\t// step-up (A03) reuses this: a login that already cleared a second factor\n\t\t// is not asked to step up again.\n\t\tlet mfaSatisfiedThisRequest = false\n\t\tconst hasActiveTotp = Boolean(person.otp_secret && person.otp_activated_at)\n\t\tif (hasActiveTotp) {\n\t\t\t// Active TOTP takes precedence (A07 path, unchanged).\n\t\t\tif (otpCode) {\n\t\t\t\tif (!await this.otpManager.verifyOtp(dbContext, person, otpCode)) {\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t\t}\n\t\t\t\tmfaSatisfiedThisRequest = true\n\t\t\t} else if (backupCode) {\n\t\t\t\tif (!await this.backupCodeManager.verifyAndConsume(dbContext, person, backupCode)) {\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t\t}\n\t\t\t\tusedBackupCode = true\n\t\t\t\tmfaSatisfiedThisRequest = true\n\t\t\t} else {\n\t\t\t\treturn new ResponseError('OTP_REQUIRED', `2FA is enabled. OTP token is required`, authLogData)\n\t\t\t}\n\t\t} else if (person.email_otp_enabled) {\n\t\t\t// No active TOTP, but email OTP is enabled (A05).\n\t\t\tif (otpCode) {\n\t\t\t\tif (!await this.emailOtpManager.verifyAndConsume(dbContext, person, otpCode)) {\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t\t}\n\t\t\t\tmfaSatisfiedThisRequest = true\n\t\t\t} else if (backupCode) {\n\t\t\t\tif (!await this.backupCodeManager.verifyAndConsume(dbContext, person, backupCode)) {\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t\t}\n\t\t\t\tusedBackupCode = true\n\t\t\t\tmfaSatisfiedThisRequest = true\n\t\t\t} else {\n\t\t\t\t// Dispatch a fresh code and ask the client to retry with otpToken. Reuses\n\t\t\t\t// the existing OTP_REQUIRED contract so clients that already handle it work.\n\t\t\t\t// The send is rate-limited per person; when throttled nothing is emailed\n\t\t\t\t// (emailOtpSent: false) but a previously emailed code stays valid.\n\t\t\t\t// Independent fetch (not the passed-in `config`): the email-OTP send is\n\t\t\t\t// rate-limited by the live config and this branch never reaches the\n\t\t\t\t// anomaly analyzer, so query shape stays as before A03.\n\t\t\t\tconst emailOtpConfig = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\t\t\t\tconst decision = await this.emailOtpManager.sendCode(dbContext, person, emailOtpConfig)\n\t\t\t\treturn new ResponseError('OTP_REQUIRED', `2FA is enabled. OTP token is required`, {\n\t\t\t\t\temailOtpSent: decision.ok,\n\t\t\t\t\t...authLogData,\n\t\t\t\t})\n\t\t\t}\n\t\t} else {\n\t\t\t// No active factor at all (A06). Enforce MFA if a matching auth_policy\n\t\t\t// requires it. With no policy rows configured this resolves to\n\t\t\t// mfaRequired=false and sign-in proceeds exactly as before.\n\t\t\tconst enforcement = await this.enforceMfaEnrollment(dbContext, person, otpCode, authLogData)\n\t\t\tif (enforcement !== null) {\n\t\t\t\tif ('error' in enforcement) {\n\t\t\t\t\treturn enforcement\n\t\t\t\t}\n\t\t\t\t// Enrollment completed in this request (pending TOTP promoted to active),\n\t\t\t\t// so MFA is definitively satisfied — anomaly step-up cannot apply.\n\t\t\t\tconst riskHandled = await this.applyRiskPolicy(dbContext, person, {\n\t\t\t\t\tconfig,\n\t\t\t\t\tgeoCountry,\n\t\t\t\t\trequestInfo,\n\t\t\t\t\totpCode,\n\t\t\t\t\tmfaSatisfiedThisRequest: true,\n\t\t\t\t\tauthLogData,\n\t\t\t\t})\n\t\t\t\tif (riskHandled !== null && 'error' in riskHandled) {\n\t\t\t\t\treturn riskHandled\n\t\t\t\t}\n\t\t\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(\n\t\t\t\t\tdbContext,\n\t\t\t\t\tperson.identity_id,\n\t\t\t\t\texpiration,\n\t\t\t\t\trequestInfo,\n\t\t\t\t\ttrustForwardedInfo,\n\t\t\t\t)\n\t\t\t\treturn new ResponseOk({\n\t\t\t\t\tperson,\n\t\t\t\t\ttoken: sessionToken,\n\t\t\t\t\tbackupCodes: enforcement.backupCodes,\n\t\t\t\t\tunusualLoginDetected: riskHandled?.unusualLoginDetected || undefined,\n\t\t\t\t\trisk: riskHandled?.risk,\n\t\t\t\t\t...authLogData,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tconst riskHandled = await this.applyRiskPolicy(dbContext, person, {\n\t\t\tconfig,\n\t\t\tgeoCountry,\n\t\t\trequestInfo,\n\t\t\totpCode,\n\t\t\tmfaSatisfiedThisRequest,\n\t\t\tauthLogData,\n\t\t})\n\t\tif (riskHandled !== null && 'error' in riskHandled) {\n\t\t\treturn riskHandled\n\t\t}\n\n\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(dbContext, person.identity_id, expiration, requestInfo, trustForwardedInfo)\n\n\t\treturn new ResponseOk({\n\t\t\tperson,\n\t\t\ttoken: sessionToken,\n\t\t\tusedBackupCode,\n\t\t\tunusualLoginDetected: riskHandled?.unusualLoginDetected || undefined,\n\t\t\trisk: riskHandled?.risk,\n\t\t\t...authLogData,\n\t\t})\n\t}\n\n\t/**\n\t * A03 — evaluate the sign-in risk score and act per policy. Returns:\n\t * - `null` when anomaly detection is off or no `config` was supplied (the\n\t * default): no DB work, sign-in proceeds unchanged.\n\t * - `{ unusualLoginDetected: true }` when an informational UNUSUAL_LOGIN email\n\t * was sent (best-effort) but the session may still be issued.\n\t * - a `ResponseError('OTP_REQUIRED')` when step-up is required and no code was\n\t * supplied — reuses the email-OTP step-up channel (dispatches a code).\n\t * - a `ResponseError('INVALID_OTP_TOKEN')` when a step-up code was supplied but\n\t * did not verify. A valid code consumes the token and the session is issued.\n\t * - `{ unusualLoginDetected: false }` when the score was below every threshold.\n\t *\n\t * The audit entries (`unusual_login_detected`, `step_up_required`) are emitted\n\t * by the resolver from the response metadata, matching the project convention.\n\t */\n\tprivate async applyRiskPolicy(\n\t\tdbContext: DatabaseContext,\n\t\tperson: PersonRow,\n\t\targs: {\n\t\t\tconfig?: Config\n\t\t\tgeoCountry?: string\n\t\t\trequestInfo?: ApiKeyRequestInfo\n\t\t\totpCode?: string\n\t\t\tmfaSatisfiedThisRequest: boolean\n\t\t\tauthLogData: { [AuthLogService.Key]: AuthLogService.Bag }\n\t\t},\n\t): Promise<RiskPolicyOutcome> {\n\t\tconst { config, geoCountry, requestInfo, otpCode, mfaSatisfiedThisRequest, authLogData } = args\n\t\tconst policy = config?.login.anomalyDetection\n\t\tif (!config || !policy?.enabled) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst assessment = await this.loginRiskAnalyzer.analyze(dbContext, person.id, {\n\t\t\tgeoCountry: geoCountry ?? null,\n\t\t\tdeviceFingerprint: this.loginRiskAnalyzer.fingerprint(requestInfo?.userAgent),\n\t\t\tip: requestInfo?.ip ?? null,\n\t\t}, policy)\n\n\t\tif (assessment.action === 'allow') {\n\t\t\treturn { unusualLoginDetected: false }\n\t\t}\n\n\t\tconst riskMeta = { score: assessment.score, reasons: assessment.reasons }\n\n\t\tif (assessment.action === 'stepUp' && !mfaSatisfiedThisRequest) {\n\t\t\t// Reuse the existing email-OTP step-up channel. This is independent of\n\t\t\t// `person.email_otp_enabled`, which gates email OTP only as the *primary*\n\t\t\t// second factor (A05); the step-up challenge stands on its own, so a person\n\t\t\t// with no standing MFA can still complete it. Without consuming the code\n\t\t\t// here, such a person would never satisfy the step-up and would loop on\n\t\t\t// OTP_REQUIRED forever (the score is unchanged on every retry).\n\t\t\tif (otpCode) {\n\t\t\t\t// Retry leg: the client resent the emailed code as otpToken. Mirror the\n\t\t\t\t// A05 contract — an invalid code is rejected (not re-sent); the per-code\n\t\t\t\t// 3-attempt cap still applies, and a fresh code is issued only when the\n\t\t\t\t// client retries without a code.\n\t\t\t\tif (!await this.emailOtpManager.verifyAndConsume(dbContext, person, otpCode)) {\n\t\t\t\t\t// A failed *forced* second factor is itself an anomaly signal — carry the\n\t\t\t\t\t// step-up/risk metadata so the resolver still emits unusual_login_detected\n\t\t\t\t\t// + step_up_required with the score/reasons, rather than logging it as an\n\t\t\t\t\t// ordinary mistyped-OTP login indistinguishable from a normal 2FA typo.\n\t\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', {\n\t\t\t\t\t\tstepUpRequired: true,\n\t\t\t\t\t\tunusualLoginDetected: true,\n\t\t\t\t\t\trisk: riskMeta,\n\t\t\t\t\t\t...authLogData,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\t// Step-up satisfied this request — fall through to notify + allow below.\n\t\t\t} else {\n\t\t\t\t// First leg: dispatch a code and ask the client to retry with otpToken.\n\t\t\t\t// Rate-limited per person; a previously emailed code stays valid when throttled.\n\t\t\t\tconst decision = await this.emailOtpManager.sendCode(dbContext, person, config)\n\t\t\t\treturn new ResponseError('OTP_REQUIRED', 'Additional verification is required for this sign-in', {\n\t\t\t\t\temailOtpSent: decision.ok,\n\t\t\t\t\tstepUpRequired: true,\n\t\t\t\t\tunusualLoginDetected: true,\n\t\t\t\t\trisk: riskMeta,\n\t\t\t\t\t...authLogData,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\t// action === 'email', or step-up satisfied (already this request, or just now\n\t\t// via the emailed code): notify the person of the unusual sign-in. A failed\n\t\t// email must never block sign-in.\n\t\tawait this.sendUnusualLoginEmail(dbContext, person, geoCountry ?? null, requestInfo?.ip ?? null)\n\t\treturn { unusualLoginDetected: true, risk: riskMeta }\n\t}\n\n\tprivate async sendUnusualLoginEmail(dbContext: DatabaseContext, person: PersonRow, geoCountry: string | null, ip: string | null): Promise<void> {\n\t\tif (!person.email) {\n\t\t\t// No address to notify (e.g. an IDP-only person) — nothing to send.\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\tawait this.userMailer.sendUnusualLoginEmail(dbContext, {\n\t\t\t\temail: person.email,\n\t\t\t\tgeoCountry,\n\t\t\t\tipAddress: ip,\n\t\t\t}, { projectId: null, variant: '' })\n\t\t} catch {\n\t\t\t// ignore — informational only\n\t\t}\n\t}\n\n\t/**\n\t * A06 enforcement for the no-active-factor case. Returns:\n\t * - `null` when MFA is not required, or grace currently allows sign-in\n\t * (caller proceeds to issue a session as normal). With zero auth_policy\n\t * rows this is always the outcome — no behavior change.\n\t * - a `ResponseError` (MFA_ENROLLMENT_REQUIRED with the pending secret to\n\t * enroll, or INVALID_OTP_TOKEN) when sign-in must be blocked.\n\t * - `{ ok: true, backupCodes }` when enrollment just completed (pending TOTP\n\t * promoted to active); caller issues the session.\n\t */\n\tprivate async enforceMfaEnrollment(\n\t\tdbContext: DatabaseContext,\n\t\tperson: PersonRow,\n\t\totpCode: string | undefined,\n\t\tauthLogData: { [AuthLogService.Key]: AuthLogService.Bag },\n\t): Promise<\n\t\t| null\n\t\t| ResponseError<SignInErrorCode, {\n\t\t\temailOtpSent?: boolean\n\t\t\tmfaEnrollmentRequired?: boolean\n\t\t\tmfaEnrollment?: MfaEnrollment\n\t\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t\t}>\n\t\t| { backupCodes: string[] }\n\t> {\n\t\tconst policy = await this.authPolicyResolver.resolveForIdentity(dbContext, person.identity_id, person.roles)\n\t\tif (!policy.mfaRequired) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst now = this.providers.now()\n\n\t\t// Resolve the effective grace duration (seconds). A per-policy override\n\t\t// wins; otherwise we fall back to the global config default. Only fetch the\n\t\t// config when the override is absent, to keep query shapes minimal.\n\t\tlet graceSeconds: number\n\t\tif (policy.graceDuration !== null) {\n\t\t\tgraceSeconds = intervalToSeconds(policy.graceDuration)\n\t\t} else {\n\t\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\t\t\tgraceSeconds = intervalToSeconds(config.login.mfaGraceDuration)\n\t\t}\n\n\t\t// Grace handling. Default grace duration is 0 (immediate enforcement), so\n\t\t// by default neither branch below opens a window.\n\t\tif (person.mfa_grace_until !== null) {\n\t\t\tif (person.mfa_grace_until.getTime() > now.getTime()) {\n\t\t\t\t// Still inside an open grace window — allow sign-in.\n\t\t\t\treturn null\n\t\t\t}\n\t\t\t// Grace expired → fall through to enforcement.\n\t\t} else if (graceSeconds > 0) {\n\t\t\t// Open the grace window now and allow this sign-in.\n\t\t\tconst graceUntil = new Date(now.getTime() + graceSeconds * 1000)\n\t\t\tawait dbContext.commandBus.execute(new SetMfaGraceUntilCommand(person.id, graceUntil))\n\t\t\treturn null\n\t\t}\n\n\t\t// Enforce.\n\t\tif (otpCode && person.otp_pending_secret) {\n\t\t\t// Enrollment completion: verify against the pending secret, then promote.\n\t\t\tif (!await this.otpManager.verifyPendingOtp(person, otpCode)) {\n\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t}\n\t\t\tconst promoted = await this.otpManager.confirmOtp(dbContext, person)\n\t\t\tif (!promoted) {\n\t\t\t\t// Lost a concurrent enrollment-completion race: the pending secret was\n\t\t\t\t// already promoted by the other request (which issued the backup codes).\n\t\t\t\t// Don't regenerate here — that would invalidate the codes already\n\t\t\t\t// returned. TOTP is now active, so a retry signs in via the normal path.\n\t\t\t\treturn new ResponseError('INVALID_OTP_TOKEN', 'OTP token validation has failed', authLogData)\n\t\t\t}\n\t\t\tconst backupCodes = await this.backupCodeManager.generate(dbContext, person.id)\n\t\t\treturn { backupCodes }\n\t\t}\n\n\t\t// Provision a fresh pending TOTP secret and ask the client to enroll.\n\t\tconst otp = await this.otpManager.prepareOtp(dbContext, person, 'Contember')\n\t\tconst mfaEnrollment: MfaEnrollment = { otpUri: otp.uri, otpSecret: otp.secret }\n\t\treturn new ResponseError('MFA_ENROLLMENT_REQUIRED', 'MFA enrollment is required', {\n\t\t\tmfaEnrollmentRequired: true,\n\t\t\tmfaEnrollment,\n\t\t\t...authLogData,\n\t\t})\n\t}\n\n\tasync createSessionToken(\n\t\tdbContext: DatabaseContext,\n\t\tpersonIdentifier: PersonUniqueIdentifier,\n\t\texpiration?: number,\n\t\tverifier?: (person: PersonRow) => Promise<void>,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateSessionTokenResponse> {\n\t\tconst person = await dbContext.queryHandler.fetch(PersonQuery.byUniqueIdentifier(personIdentifier))\n\n\t\tif (person === null) {\n\t\t\tif (personIdentifier.type === 'email') {\n\t\t\t\treturn new ResponseError('UNKNOWN_EMAIL', `Person with email ${personIdentifier.email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: personIdentifier.email,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t} else if (personIdentifier.type === 'id') {\n\t\t\t\treturn new ResponseError('UNKNOWN_PERSON_ID', `Person with id ${personIdentifier.id} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tpersonInput: personIdentifier.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tthrow new ImplementationException()\n\t\t}\n\n\t\tconst inputValue = personIdentifier.type === 'email' ? personIdentifier.email : personIdentifier.id\n\t\tif (person.disabled_at !== null) {\n\t\t\treturn new ResponseError('PERSON_DISABLED', `Person with id ${person.id} is disabled`, {\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: person.id,\n\t\t\t\t\tpersonInput: inputValue,\n\t\t\t\t}),\n\t\t\t})\n\t\t}\n\n\t\tawait verifier?.(person)\n\n\t\tconst sessionToken = await this.apiKeyManager.createSessionApiKey(dbContext, person.identity_id, expiration, requestInfo, trustForwardedInfo)\n\t\treturn new ResponseOk({\n\t\t\tperson,\n\t\t\ttoken: sessionToken,\n\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\tpersonId: person.id,\n\t\t\t\tpersonInput: inputValue,\n\t\t\t}),\n\t\t})\n\t}\n}\n\n/** A03: greppable payload describing why a sign-in scored as unusual. */\nexport interface RiskMeta {\n\treadonly score: number\n\treadonly reasons: readonly string[]\n}\n\n/**\n * Result of {@link SignInManager.applyRiskPolicy}: `null` = feature off / no\n * config; a metadata object when the session may still be issued; a\n * `ResponseError` when step-up blocks the session.\n */\ntype RiskPolicyOutcome =\n\t| null\n\t| { unusualLoginDetected: boolean; risk?: RiskMeta }\n\t| ResponseError<SignInErrorCode, {\n\t\temailOtpSent?: boolean\n\t\tstepUpRequired?: boolean\n\t\tunusualLoginDetected?: boolean\n\t\trisk?: RiskMeta\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n\nexport interface SignInResult {\n\treadonly person: PersonRow\n\treadonly token: string\n\t/** True when MFA was satisfied by consuming a backup code (instead of a TOTP token). */\n\treadonly usedBackupCode?: boolean\n\t/** Set when sign-in completed an MFA enrollment (A06): the freshly issued backup codes, shown once. */\n\treadonly backupCodes?: string[]\n\t/** A03: true when this sign-in scored as unusual and an UNUSUAL_LOGIN email was sent. */\n\treadonly unusualLoginDetected?: boolean\n\t/** A03: the risk score + reasons, recorded in the `unusual_login_detected` audit `event_data`. */\n\treadonly risk?: RiskMeta\n\t[AuthLogService.Key]: AuthLogService.Bag\n}\n\nexport type SignInResponse = Response<SignInResult, SignInErrorCode, {\n\t/** Set on an OTP_REQUIRED error when an email-OTP code was just dispatched (A05). */\n\temailOtpSent?: boolean\n\t/** Set on a MFA_ENROLLMENT_REQUIRED error (A06): triggers the `mfa_enrollment_required` audit. */\n\tmfaEnrollmentRequired?: boolean\n\t/** Set on a MFA_ENROLLMENT_REQUIRED error (A06): the pending secret the client must enroll. */\n\tmfaEnrollment?: MfaEnrollment\n\t/** Set on an OTP_REQUIRED error raised by A03 anomaly step-up: triggers the `step_up_required` audit. */\n\tstepUpRequired?: boolean\n\t/** A03: true when the sign-in scored as unusual (triggers the `unusual_login_detected` audit). */\n\tunusualLoginDetected?: boolean\n\t/** A03: the risk score + reasons, recorded in the audit `event_data`. */\n\trisk?: RiskMeta\n\t[AuthLogService.Key]: AuthLogService.Bag\n}>\nexport type CreateSessionTokenResponse = Response<SignInResult, CreateSessionTokenErrorCode, {\n\t[AuthLogService.Key]: AuthLogService.Bag\n}>\n\nexport { SignInManager }\n"],"names":["PersonQuery","ResponseError","AuthLogService","LocalAuthDisablingIdpsQuery","ConfigurationQuery","riskHandled","sessionToken","ResponseOk","intervalToSeconds","SetMfaGraceUntilCommand","ImplementationException"],"mappings":"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"}
|
|
@@ -4,6 +4,7 @@ import "../type/ApiKey.js";
|
|
|
4
4
|
import { ConfigurationQuery } from "../queries/configuration/ConfigurationQuery.js";
|
|
5
5
|
import { ImplementationException } from "../../exceptions.js";
|
|
6
6
|
import { PersonQuery } from "../queries/person/PersonQuery.js";
|
|
7
|
+
import { LocalAuthDisablingIdpsQuery } from "../queries/idp/LocalAuthDisablingIdpsQuery.js";
|
|
7
8
|
import "../authorization/PermissionActions.js";
|
|
8
9
|
import "@contember/authorization";
|
|
9
10
|
import "../authorization/PermissionContext.js";
|
|
@@ -40,6 +41,10 @@ class SignInManager {
|
|
|
40
41
|
personId: person.id
|
|
41
42
|
})
|
|
42
43
|
};
|
|
44
|
+
const exclusiveIdps = await dbContext.queryHandler.fetch(new LocalAuthDisablingIdpsQuery(person.id));
|
|
45
|
+
if (exclusiveIdps.length > 0) {
|
|
46
|
+
return new ResponseError("IDP_REQUIRED", `Sign in using ${exclusiveIdps.join(" or ")}`, authLogData);
|
|
47
|
+
}
|
|
43
48
|
if (!person.password_hash) {
|
|
44
49
|
return new ResponseError("NO_PASSWORD_SET", `No password set`, authLogData);
|
|
45
50
|
}
|