From 14097ab734ec102b1bb26bf710ba11d3cacaf345 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Mon, 17 Aug 2026 01:47:53 +0300 Subject: [PATCH 01/12] feat(agent): publish an agent so flow steps have something to run (#14846) --- packages/core/shared/package.json | 2 +- .../core/shared/src/lib/ee/agent/agent.ts | 5 + .../shared/src/lib/ee/audit-events/index.ts | 4 + .../lib/ee/audit-events/mock-event-builder.ts | 3 +- .../api/src/app/ee/agent/agent-controller.ts | 32 ++++ .../api/src/app/ee/agent/agent-service.ts | 51 +++++-- .../ee/agent/agent-controller.test.ts | 137 +++++++++++++++++- 7 files changed, 221 insertions(+), 13 deletions(-) diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index d7d6847ba38..b5798233884 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.133.0", + "version": "0.134.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/ee/agent/agent.ts b/packages/core/shared/src/lib/ee/agent/agent.ts index e87f83d2eb9..b3d48d533ea 100644 --- a/packages/core/shared/src/lib/ee/agent/agent.ts +++ b/packages/core/shared/src/lib/ee/agent/agent.ts @@ -75,8 +75,13 @@ const ListAgentsRequest = z.object({ limit: z.coerce.number().int().min(1).max(MAX_AGENT_PAGE_SIZE).optional(), }) +const agentUtils = { + isPublishable: (config: AgentConfig): boolean => (config.instructions ?? '').trim().length > 0, +} + export { Agent, + agentUtils, AgentConfig, AgentIcon, AgentVisibility, diff --git a/packages/core/shared/src/lib/ee/audit-events/index.ts b/packages/core/shared/src/lib/ee/audit-events/index.ts index 5e2bc10df1a..cce049fd5d5 100644 --- a/packages/core/shared/src/lib/ee/audit-events/index.ts +++ b/packages/core/shared/src/lib/ee/audit-events/index.ts @@ -37,6 +37,7 @@ export enum ApplicationEventName { AGENT_CREATED = 'agent.created', AGENT_UPDATED = 'agent.updated', AGENT_DELETED = 'agent.deleted', + AGENT_PUBLISHED = 'agent.published', VARIABLE_UPSERTED = 'variable.upserted', VARIABLE_DELETED = 'variable.deleted', VARIABLE_VALUE_REVEALED = 'variable.value.revealed', @@ -115,6 +116,7 @@ export const AgentAuditEvent = z.object({ z.literal(ApplicationEventName.AGENT_CREATED), z.literal(ApplicationEventName.AGENT_UPDATED), z.literal(ApplicationEventName.AGENT_DELETED), + z.literal(ApplicationEventName.AGENT_PUBLISHED), ]), data: AgentEventData, }) @@ -562,6 +564,8 @@ export function summarizeApplicationEvent(event: ApplicationEvent) { return `Agent ${event.data.agent.displayName} is updated` case ApplicationEventName.AGENT_DELETED: return `Agent ${event.data.agent.displayName} is deleted` + case ApplicationEventName.AGENT_PUBLISHED: + return `Agent ${event.data.agent.displayName} is published` case ApplicationEventName.VARIABLE_UPSERTED: return `Variable ${event.data.variable.name} is created or updated` case ApplicationEventName.VARIABLE_DELETED: diff --git a/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts b/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts index ec0c0999233..4cc46a8a48d 100644 --- a/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts +++ b/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts @@ -171,7 +171,8 @@ export const buildMockEvent = ({ event, platformId, projectId }: BuildMockEventP } case ApplicationEventName.AGENT_CREATED: case ApplicationEventName.AGENT_UPDATED: - case ApplicationEventName.AGENT_DELETED: { + case ApplicationEventName.AGENT_DELETED: + case ApplicationEventName.AGENT_PUBLISHED: { const mock: AgentAuditEvent = { ...baseEnvelope, action: event, diff --git a/packages/server/api/src/app/ee/agent/agent-controller.ts b/packages/server/api/src/app/ee/agent/agent-controller.ts index eb60fd529dd..87ba90b75b0 100644 --- a/packages/server/api/src/app/ee/agent/agent-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-controller.ts @@ -58,6 +58,19 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { return agent }) + app.post('/:id/publish', PublishAgentRoute, async (request): Promise => { + const agent = await agentService(request.log).publish({ + id: request.params.id, + projectId: request.projectId, + userId: await resolveUserId(request), + }) + applicationEvents(request.log).sendUserEvent(request, { + action: ApplicationEventName.AGENT_PUBLISHED, + data: { agent: { id: agent.id, displayName: agent.displayName } }, + }) + return agent + }) + app.delete('/:id', DeleteAgentRoute, async (request, reply): Promise => { const agent = await agentService(request.log).delete({ id: request.params.id, @@ -151,6 +164,25 @@ const UpdateAgentRoute = { }, } +const PublishAgentRoute = { + config: { + security: securityAccess.project( + [PrincipalType.USER, PrincipalType.SERVICE], + Permission.WRITE_AGENT, + { type: ProjectResourceType.TABLE, tableName: AgentEntity }, + ), + }, + schema: { + tags: ['agents'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + description: 'Publish an agent, so flow steps run the current draft', + params: z.object({ id: ApId }), + response: { + [StatusCodes.OK]: Agent, + }, + }, +} + const DeleteAgentRoute = { config: { security: securityAccess.project( diff --git a/packages/server/api/src/app/ee/agent/agent-service.ts b/packages/server/api/src/app/ee/agent/agent-service.ts index 487b60cdc6a..568f74c0714 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -1,5 +1,5 @@ -import { ActivepiecesError, ApId, apId, Cursor, ErrorCode, isNil, Permission, PlatformId, ProjectId, SeekPage, UserId } from '@activepieces/core-utils' -import { Agent, AgentVisibility, CreateAgentRequest, UpdateAgentRequest } from '@activepieces/shared' +import { ActivepiecesError, ApId, apId, Cursor, ErrorCode, isNil, omit, Permission, PlatformId, ProjectId, sanitizeObjectForPostgresql, SeekPage, UserId } from '@activepieces/core-utils' +import { Agent, agentUtils, AgentVisibility, CreateAgentRequest, UpdateAgentRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { Brackets, In, SelectQueryBuilder } from 'typeorm' import { repoFactory } from '../../core/db/repo-factory' @@ -28,7 +28,7 @@ export const agentService = (log: FastifyBaseLogger) => ({ color: request.color, visibility, sharedWithUserIds: await resolveShare({ visibility, sharedWithUserIds: request.sharedWithUserIds, projectId, log }), - draft: request.draft, + draft: sanitizeObjectForPostgresql(request.draft), published: null, }) }, @@ -73,7 +73,36 @@ export const agentService = (log: FastifyBaseLogger) => ({ projectId, log, }) - return agentRepo().save({ ...agent, ...request, visibility, sharedWithUserIds }) + const draft = isNil(request.draft) ? agent.draft : sanitizeObjectForPostgresql(request.draft) + return agentRepo().save({ ...omit(agent, ['published']), ...request, draft, visibility, sharedWithUserIds }) + }, + + async publish({ id, projectId, userId }: GetParams): Promise { + const agent = await this.getOneOrThrow({ id, projectId, userId }) + if (!agentUtils.isPublishable(agent.draft)) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'An agent needs instructions before it can be published' }, + }) + } + const published = await agentRepo() + .createQueryBuilder() + .update() + .set({ published: () => '"draft"' }) + .where('"id" = :id AND "projectId" = :projectId', { id, projectId }) + .andWhere('"draft" = CAST(:reviewedDraft AS jsonb)', { reviewedDraft: JSON.stringify(agent.draft) }) + .andWhere(visibleToUser({ userId, prefix: '' })) + .returning('id') + .execute() + + const publishedRows: unknown[] = published.raw ?? [] + if (publishedRows.length === 0) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'The agent changed while it was being published, review it and publish again' }, + }) + } + return this.getOneOrThrow({ id, projectId, userId }) }, async delete({ id, projectId, userId }: GetParams): Promise { @@ -86,11 +115,15 @@ export const agentService = (log: FastifyBaseLogger) => ({ function visibleAgents({ userId }: { userId: UserId }): SelectQueryBuilder { return agentRepo() .createQueryBuilder('agent') - .where(new Brackets((qb) => { - qb.where('agent.visibility = :projectVisibility', { projectVisibility: AgentVisibility.PROJECT }) - .orWhere('agent."ownerId" = :userId', { userId }) - .orWhere(':userId = ANY(agent."sharedWithUserIds")', { userId }) - })) + .where(visibleToUser({ userId, prefix: 'agent.' })) +} + +function visibleToUser({ userId, prefix }: { userId: UserId, prefix: string }): Brackets { + return new Brackets((qb) => { + qb.where(`${prefix}"visibility" = :projectVisibility`, { projectVisibility: AgentVisibility.PROJECT }) + .orWhere(`${prefix}"ownerId" = :userId`, { userId }) + .orWhere(`:userId = ANY(${prefix}"sharedWithUserIds")`, { userId }) + }) } async function resolveShare({ visibility, sharedWithUserIds, projectId, log }: ResolveShareParams): Promise { diff --git a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts index 056457da2e2..e8b7d1b397f 100644 --- a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts +++ b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts @@ -2,6 +2,7 @@ import { apId } from '@activepieces/core-utils' import { AgentIcon, AgentVisibility, ColorName, DefaultProjectRole } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' +import { db } from '../../../helpers/db' import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' @@ -73,6 +74,131 @@ describe('agent crud', () => { }) }) +describe('agent publish', () => { + it('copies the draft to published, so a flow step has something to run', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + const response = await ctx.post(`/v1/agents/${agent.id}/publish`) + + expect(response.statusCode).toBe(StatusCodes.OK) + expect(response.json().published).toStrictEqual(response.json().draft) + }) + + it('leaves the published copy alone when the draft moves on', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await ctx.post(`/v1/agents/${agent.id}/publish`) + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Rewritten.' } }) + + const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() + expect(after.draft.instructions).toBe('Rewritten.') + expect(after.published.instructions).toBe('Draft launch posts.') + }) + + it.each([['spaces', ' '], ['tabs', '\t\t'], ['newlines', '\n\n'], ['empty', '']])( + 'refuses to publish an agent whose instructions are only %s', + async (_kind, instructions) => { + const ctx = await context() + const agent = await createAgent(ctx, { draft: { ...agentBody(ctx.project.id).draft, instructions } }) + + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.CONFLICT) + expect((await ctx.get(`/v1/agents/${agent.id}`)).json().published).toBeNull() + }) + + it('keeps a rich config byte-for-byte through the copy', async () => { + const ctx = await context() + const draft = { + instructions: 'Check the brand guide first.', + provider: null, + modelName: 'claude-sonnet-4-6', + maxSteps: 7, + tools: [], + structuredOutput: [{ displayName: 'summary', type: 'text' }], + } + const agent = await createAgent(ctx, { draft }) + + const published = (await ctx.post(`/v1/agents/${agent.id}/publish`)).json().published + expect(published).toStrictEqual(draft) + }) + + it('republishes the newer draft, and is a no-op when nothing changed', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + const first = (await ctx.post(`/v1/agents/${agent.id}/publish`)).json() + const again = (await ctx.post(`/v1/agents/${agent.id}/publish`)).json() + expect(again.published).toStrictEqual(first.published) + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Second version.' } }) + const third = (await ctx.post(`/v1/agents/${agent.id}/publish`)).json() + expect(third.published.instructions).toBe('Second version.') + }) + + it('keeps the published config when the agent is edited afterwards', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await ctx.post(`/v1/agents/${agent.id}/publish`) + + await ctx.post(`/v1/agents/${agent.id}`, { displayName: 'Renamed' }) + + const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() + expect(after.displayName).toBe('Renamed') + expect(after.published).not.toBeNull() + }) + + it('publishes a piece tool with its predefined input intact', async () => { + const ctx = await context() + const draft = { + instructions: 'File the ticket.', + provider: null, + modelName: null, + maxSteps: 3, + tools: [{ + type: 'PIECE', + toolName: 'create_issue', + pieceMetadata: { + pieceName: '@activepieces/piece-github', + pieceVersion: '0.1.0', + actionName: 'create_issue', + predefinedInput: { fields: { title: { mode: 'choose-yourself', value: 'Bug' } } }, + }, + }], + structuredOutput: [], + } + const agent = await createAgent(ctx, { draft }) + + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).json().published).toStrictEqual(draft) + }) + + it('refuses instructions that are only a non-breaking space', async () => { + const ctx = await context() + const agent = await createAgent(ctx, { draft: { ...agentBody(ctx.project.id).draft, instructions: '\u00a0' } }) + + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.CONFLICT) + }) + + it('refuses to publish a restricted agent the caller cannot see', async () => { + const owner = await context() + const member = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const agent = await createAgent(owner, { visibility: AgentVisibility.RESTRICTED }) + + expect((await member.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.NOT_FOUND) + expect((await owner.get(`/v1/agents/${agent.id}`)).json().published).toBeNull() + }) + + it('refuses a viewer, and an agent in another project', async () => { + const owner = await context() + const viewer = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.VIEWER }) + const stranger = await context() + const agent = await createAgent(owner) + + expect((await viewer.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await stranger.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await owner.get(`/v1/agents/${agent.id}`)).json().published).toBeNull() + }) +}) + describe('agent project isolation', () => { it.each([ ['read', (ctx: TestContext, id: string) => ctx.get(`/v1/agents/${id}`)], @@ -182,10 +308,17 @@ describe('agent routes coexist with the chat routes already on /v1/agents', () = }) describe('agent feature gate', () => { - it('refuses every agent route when the platform does not have agents', async () => { - const ctx = await createTestContext(app, { plan: { agentsEnabled: false } }) + it('refuses every agent route once the platform loses the entitlement', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + const plan = await db.findOneByOrFail<{ id: string }>('platform_plan', { platformId: ctx.platform.id }) + await db.update('platform_plan', plan.id, { agentsEnabled: false }) expect((await ctx.get('/v1/agents')).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) expect((await ctx.post('/v1/agents', agentBody(ctx.project.id))).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.get(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.post(`/v1/agents/${agent.id}`, { displayName: 'x' })).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) }) }) From b9bec86634bbf9ee927de0a3ba0d25982a163294 Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:18:53 +0300 Subject: [PATCH 02/12] feat(auth): move the OTP primitive into core and give it an attempt budget (#14685) --- .../connections-auth/ce-authentication.md | 10 +- .../ee-authentication-sso-rbac.md | 9 +- brain/knowledge/connections-auth/index.md | 4 +- ...yped-code-on-the-existing-otp-primitive.md | 43 +++++ packages/core/shared/package.json | 2 +- .../core/shared/src/lib/ee/otp/otp-model.ts | 1 + .../shared/src/lib/ee/otp/otp-requests.ts | 2 +- .../core/shared/src/lib/ee/otp/otp-type.ts | 1 + packages/server/api/src/app/app.ts | 3 +- .../authentication/authentication.service.ts | 2 +- .../authentication/otp/lib/otp-generator.ts | 18 ++ .../authentication/otp/otp-controller.ts | 18 +- .../{ee => }/authentication/otp/otp-entity.ts | 7 +- .../{ee => }/authentication/otp/otp-module.ts | 0 .../src/app/authentication/otp/otp-service.ts | 123 ++++++++++++++ .../api/src/app/core/security/rate-limit.ts | 10 +- .../src/app/database/database-connection.ts | 2 +- .../1824000000000-AddAttemptsToOtp.ts | 21 +++ .../src/app/database/postgres-connection.ts | 2 + .../enterprise-local-authn-service.ts | 2 +- .../authentication/otp/lib/otp-generator.ts | 7 - .../app/ee/authentication/otp/otp-service.ts | 91 ---------- .../helper/email/email-sender/email-sender.ts | 5 + .../email/email-sender/smtp-email-sender.ts | 1 + .../src/app/ee/helper/email/email-service.ts | 63 ++++--- .../api/src/assets/emails/login-code.html | 79 +++++++++ .../ce/authentication/otp-service.test.ts | 156 ++++++++++++++++++ .../authn/enterprise-local-authn.test.ts | 2 +- .../components/reset-password-form.tsx | 2 +- 29 files changed, 532 insertions(+), 154 deletions(-) create mode 100644 brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md create mode 100644 packages/server/api/src/app/authentication/otp/lib/otp-generator.ts rename packages/server/api/src/app/{ee => }/authentication/otp/otp-controller.ts (56%) rename packages/server/api/src/app/{ee => }/authentication/otp/otp-entity.ts (89%) rename packages/server/api/src/app/{ee => }/authentication/otp/otp-module.ts (100%) create mode 100644 packages/server/api/src/app/authentication/otp/otp-service.ts create mode 100644 packages/server/api/src/app/database/migration/postgres/1824000000000-AddAttemptsToOtp.ts delete mode 100644 packages/server/api/src/app/ee/authentication/otp/lib/otp-generator.ts delete mode 100644 packages/server/api/src/app/ee/authentication/otp/otp-service.ts create mode 100644 packages/server/api/src/assets/emails/login-code.html create mode 100644 packages/server/api/test/integration/ce/authentication/otp-service.test.ts diff --git a/brain/knowledge/connections-auth/ce-authentication.md b/brain/knowledge/connections-auth/ce-authentication.md index e86f046ab6c..add5156a971 100644 --- a/brain/knowledge/connections-auth/ce-authentication.md +++ b/brain/knowledge/connections-auth/ce-authentication.md @@ -13,15 +13,21 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - `accessTokenManager`: `generateToken` (7-day JWT), `generateEngineToken`/`generateWorkerToken` (long-lived), `verifyPrincipal` (checks tokenVersion + active status). ### How it works -- Token is a short-lived JWT (7 days) signed with a shared secret. `PrincipalType`: USER, ENGINE, WORKER, SERVICE, UNKNOWN. +- Token is a short-lived JWT (7 days) signed with a shared secret. `PrincipalType`: USER, ENGINE, WORKER, SERVICE, UNKNOWN, ONBOARDING. - Endpoints (all rate-limited via `API_RATE_LIMIT_AUTHN_*`): `POST /v1/authentication/sign-up`, `/sign-in`, `/switch-platform`. -- First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → Platform (`"'s Platform"`) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry. +- First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry. +- **`signUp` has two arms and only one of them can create a platform.** When `params.platformId` is set (self-hosted, or a custom domain) the member joins that existing platform through `getOrCreateWithProject` and no platform is ever created or named. When it is nil (Cloud only) the identity is created first, then `getPreferredPlatformId` looks for a platform the identity already belongs to; finding none it returns an ONBOARDING response, and the member names the platform themselves at `/create-platform`. `getPreferredPlatformId` returns null on every non-Cloud edition. There is no `"'s Platform"` autoname in production; that string lives only in `dev-seeds.ts`. +- **ONBOARDING** is the pre-platform principal: `authenticationUtils.getOnboardingResponse` mints it with `platformId: null, projectId: null` for a verified identity that belongs to no platform yet, so the member can call `POST /v1/platforms` (`securityAccess.unscoped([ONBOARDING, USER])`) and land on `/create-platform`. It is Cloud-only in practice, because on self-hosted `platformUtils.getPlatformIdForRequest` falls back to `getOldestPlatform()` and there is always a platform to join. `accessTokenManager.assertUserSession` still revalidates it against `tokenVersion` + `verified`. +- **Passwordless sign-in** (`EMAIL_LOGIN`) is a typed 6-digit code on the same OTP primitive, offered only when `ApFlagId.SMTP_CONFIGURED` is true, with password as the fallback path. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md) for the code-not-link, edition-reach and anti-enumeration reasoning. ### Gotchas - Email-auth checks and domain allow-listing guards are **skipped on Community** edition. - OTP verification only sent on Cloud production; CE/EE and Cloud-dev (`AP_ENVIRONMENT=development`) auto-verify the identity. - Telemetry PII (email/name) sent only on Cloud; CE/EE send non-PII fields (`pickTelemetryPii`). Sign-in telemetry covers password sign-in only, not SSO. - Sessions are invalidated by rotating `tokenVersion` on `UserIdentity`. +- **A new unauthenticated endpoint must be added to `disallowedRoutes` in `packages/web/src/lib/api.ts`**, otherwise the SPA attaches whatever stale bearer token is still in storage and the call fails in exactly the situation the endpoint exists for. +- **The three signup guards in `authentication-utils.ts` differ in what they leak.** `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` describe platform configuration, so surfacing their errors is safe. `assertUserIsInvitedToPlatformOrProject` describes one address, so surfacing it turns any public auth endpoint into an invitation oracle. All three are also inert unless `plan.ssoEnabled`. +- **A nil `projectId` on the principal means "go to /create-platform" in four separate places.** Anything that mints a platform-less session has to satisfy all of them, not just the route guard. ### Key files Entry point: `authenticationService`, a log-taking factory called per request from `authentication.controller.ts`, registered as `authenticationModule` in `app.ts`. diff --git a/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md b/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md index 97da9316fd9..1c0df533c6c 100644 --- a/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md +++ b/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md @@ -13,12 +13,17 @@ Enterprise auth layer extending CE with SAML 2.0 SSO, Google/GitHub federated OA ### How it works - **SAML SSO**: `POST /v1/authn/saml/login` returns IdP redirect; IdP POSTs assertion to ACS `POST /v1/authn/saml/acs`; service parses email/name → federatedAuthn → JWT. Gated by `platform.plan.ssoEnabled`. - **Federated OAuth (Google/GitHub)**: `/v1/authn/federated/login` returns redirect URL; `/v1/authn/federated/claim` exchanges code → JWT. Redirects always use `FRONTEND_URL` (no custom domain). -- **OTP** (`EMAIL_VERIFICATION`, `PASSWORD_RESET`): per-type expiry (`OTP_EXPIRATION_MS` in `otp-service.ts`: 24h verification, 10-min reset); states PENDING/CONFIRMED. Resend re-delivers the existing pending OTP value WITHOUT touching the row — expiry stays anchored to the value's creation, so resends cannot extend a (possibly compromised) OTP's lifetime; a new value is generated only once the OTP is expired or confirmed (GIT-1733: the old early-return made resend a silent 204 no-op). Known bounded edge: a resend requested just before expiry delivers a short-lived link; the next resend regenerates. +- **OTP** (`EMAIL_VERIFICATION`, `PASSWORD_RESET`, `EMAIL_LOGIN`): per-type expiry (`OTP_EXPIRATION_MS` in `otp-service.ts`: 24h verification, 10-min reset, 10-min login); states PENDING/CONFIRMED; one row per `(identityId, type)`, DB-enforced. Resend re-delivers the existing pending value WITHOUT touching the row — expiry stays anchored to the value's creation, so resends cannot extend a (possibly compromised) OTP's lifetime; a new value is generated only once the old one is expired or spent (GIT-1733: the old early-return made resend a silent 204 no-op). The first two types carry a `randomUUID()` delivered as a link; `EMAIL_LOGIN` carries a 6-digit code the member types, and its row counts `attempts` so it dies after five wrong guesses — counted in raw SQL for the same reason resend leaves the row alone, since touching `updated` would buy the guesser another window. Known bounded edge: a resend requested just before expiry delivers a short-lived link; the next resend regenerates. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md). - **Enterprise local auth**: `verifyEmail` (confirms OTP → sets verified), `resetPassword` (confirms OTP → updates hash), both audit-logged. - **RBAC**: `assertPrincipalAccessToProject({principal, permission, projectId})` and `assertUserHasPermissionToFlow` (maps FlowOperationType → Permission). Authorization hooks: `platformMustHaveFeatureEnabled` (402 FEATURE_DISABLED), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. ### Gotchas -- CE gets OTP flows + RBAC base types; **SSO, managed auth, federated OAuth are EE/Cloud only**. +- **Until the passwordless work, CE could not send an OTP at all, despite the entity being registered for every edition.** `otpModule` was registered only in the CLOUD and ENTERPRISE arms of `app.ts`, and `emailService.sendOtp` returned early when the edition was neither. So on CE the table existed, the migration ran, and nothing could ever be sent. `EMAIL_LOGIN` changed that: `otpModule` is now registered for COMMUNITY too, and `EMAIL_LOGIN` is the one type carved out of the paid-edition send gate, so it reaches every edition while the UI gates it on `SMTP_CONFIGURED`. The two link types are still paid-edition only. RBAC base types are CE; **SSO, managed auth, federated OAuth are EE/Cloud only**. +- **The public `POST /v1/otp` route deliberately cannot mint a login code.** Its `CreateOtpRequestBody` narrows `type` to `EMAIL_VERIFICATION | PASSWORD_RESET`, because that route is unauthenticated, carries no `rateLimit` config, and applies none of the sign-up guards. `EMAIL_LOGIN` is issued only through `POST /v1/authentication/otp/request`, which is rate limited and gated. Widening that enum back to the whole `OtpType` hands anyone an unthrottled "email a working sign-in code to this address" primitive. +- **A code sign-in must re-assert the platform's auth policy at verify time, not only at request time.** On Cloud `platformUtils.getPlatformIdForRequest` returns null for every unauthenticated request, so the request-scoped branch never runs there and the platform is only known after the identity is resolved. `verifyCode` therefore calls the same `assertEmailAuthIsEnabled` + `assertDomainIsAllowed` pair on the resolved preferred platform; without that, an email code signs a member into a platform that has deliberately disabled email auth or removed their domain. It is not asserted at request time on purpose, because reporting those errors for a resolved address would turn the request endpoint into an existence oracle. +- **`otpService.confirm` used to refresh its own resend lock.** `updated` is an `updateDate` column, so marking a row CONFIRMED touched it and the ten-minute guard then refused to issue that identity another code for ten minutes after a successful verify. Rows are deleted on confirm now. +- **One constant is both the expiry and the resend suppression.** `TEN_MINUTES` gates `confirm`'s freshness check and `createAndSend`'s "an OTP already exists" early return, so before this work a resend was impossible until the current credential expired, and the request endpoint still answered 204. Resend now re-delivers the existing value without touching `updated`. +- **`email-service.ts` is not exhaustive over `OtpType`.** `frontendPath` is a literal keyed by only two members but indexed by the whole union, so adding a member is a compile break; its sibling `otpToTemplate` is typed `Record`, which type-checks and hands `undefined` to the sender at runtime instead. - SSO settings page wrapped in `LockedFeatureGuard` keyed on `ssoEnabled`. - Managed auth gated separately by `embeddingEnabled` (signing keys). See the Managed Auth page. - The authn rate limiter (`core/security/rate-limit.ts`) is registered with `global: false` — it protects NOTHING by default. Every public endpoint that sends email or does auth work must opt in per-route via `config.rateLimit` (see `authentication.controller.ts` / `otp-controller.ts` for the `API_RATE_LIMIT_AUTHN_*` pattern). diff --git a/brain/knowledge/connections-auth/index.md b/brain/knowledge/connections-auth/index.md index 872017ffd6a..279f2a8f99e 100644 --- a/brain/knowledge/connections-auth/index.md +++ b/brain/knowledge/connections-auth/index.md @@ -25,11 +25,11 @@ Platform owners register their own OAuth client_id/secret per piece so connectio ### CE Authentication -User identity, sign-in, JWT sessions. `UserIdentity` = canonical email+password+provider (one per email, shared across platforms); `User` = platform-scoped membership. First sign-up auto-creates a Platform + personal Project + ADMIN user. JWT is 7-day, signed with a shared secret; rotating `tokenVersion` on UserIdentity invalidates all sessions. `accessTokenManager` also mints long-lived engine/worker tokens. Endpoints: `/v1/authentication/sign-up|sign-in|switch-platform`. PrincipalTypes: USER/ENGINE/WORKER/SERVICE/UNKNOWN. +User identity, sign-in, JWT sessions. `UserIdentity` = canonical email+password+provider (one per email, shared across platforms); `User` = platform-scoped membership. First sign-up auto-creates a Platform + personal Project + ADMIN user. JWT is 7-day, signed with a shared secret; rotating `tokenVersion` on UserIdentity invalidates all sessions. `accessTokenManager` also mints long-lived engine/worker tokens. Endpoints: `/v1/authentication/sign-up|sign-in|switch-platform`. PrincipalTypes: USER/ENGINE/WORKER/SERVICE/UNKNOWN/ONBOARDING (the last is the pre-platform session that can only call `POST /v1/platforms`). ### EE Authentication -Extends CE with SSO + RBAC. SAML 2.0 (`/v1/authn/saml/login` → IdP → ACS `/acs`) and Google/GitHub federated OAuth both funnel into `authenticationService.federatedAuthn()`; gated by `ssoEnabled`. Per-project RBAC via `assertPrincipalAccessToProject()` and `assertUserHasPermissionToFlow()`. Config stored on `platform.federatedAuthProviders`. Authz hooks: `platformMustHaveFeatureEnabled` (402), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. OTP (email verify + password reset) lives here but is available in CE too. +Extends CE with SSO + RBAC. SAML 2.0 (`/v1/authn/saml/login` → IdP → ACS `/acs`) and Google/GitHub federated OAuth both funnel into `authenticationService.federatedAuthn()`; gated by `ssoEnabled`. Per-project RBAC via `assertPrincipalAccessToProject()` and `assertUserHasPermissionToFlow()`. Config stored on `platform.federatedAuthProviders`. Authz hooks: `platformMustHaveFeatureEnabled` (402), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. OTP (email verify, password reset, and the `EMAIL_LOGIN` sign-in code) lives here. Its entity is registered for every edition, but `otpModule` is only registered on Cloud/EE and `sendOtp` returns early off those editions, so CE can send nothing today except `EMAIL_LOGIN`, which is gated on `SMTP_CONFIGURED` instead. ### Managed Auth / Embedding (EE) diff --git a/brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md b/brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md new file mode 100644 index 00000000000..d0e5962a6ce --- /dev/null +++ b/brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md @@ -0,0 +1,43 @@ +--- +status: accepted +--- + +# Email sign-in is a typed code on the existing OTP primitive, not a second subsystem + +## Decision +Passwordless sign-in adds a third `OtpType`, `EMAIL_LOGIN`, and reuses `otpService.createAndSend` / `.confirm` rather than introducing a parallel one-time-credential mechanism. The credential is a 6-digit code the member types, never a clickable link. It reaches every edition, but the UI only offers it when `ApFlagId.SMTP_CONFIGURED` is true; password sign-in stays the default path everywhere else. + +## Context +Main already carries the whole emailed-code mechanism: a `PENDING`/`CONFIRMED` state machine, a unique index on `(identityId, type)`, a public request endpoint, and an emailed delivery path. What it lacked was shape and reach. The value was a `randomUUID()` delivered as a magic link, there were only two `OtpType` members, `otpModule` was registered for CLOUD and ENTERPRISE only, `sendOtp` returned early when `EDITION_IS_NOT_PAID`, and there was no code-entry UI on the web at all. + +A vibe-coded branch built this as new machinery and regressed three properties in the process, which is what forced the calls below. + +## Why + +**A typed code, not a link.** [000009](./000009-approval-links-require-a-post-confirmation-on-a-dedicated-route.md) established that Microsoft Safe Links, Mimecast and Proofpoint pre-fetch emailed URLs with a GET that is indistinguishable from a human click. A single-use sign-in link is consumed by that prefetch, so the member's own click lands on an expired credential. A typed code sidesteps the whole class. Shipping a link would mean rebuilding 000009's GET-page plus POST-confirm shape for auth. + +**Reach is gated on SMTP, not on edition.** `emailSender` silently falls back to `logEmailSender` when SMTP is unset, so an all-editions rollout without a gate is exactly the "looks enabled, silently broken" failure `.claude/rules/self-hosting.md` forbids. Gating on the already-public `SMTP_CONFIGURED` flag means a CE instance without SMTP sees no change at all, and one with SMTP gets the feature for free. No new flag, and `EMAIL_LOGIN` is the only type carved out of the paid-edition delivery gate. + +**A code request never becomes an oracle.** The three signup asserts split by what they reveal. `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` are properties of platform configuration and throw distinct errors, because knowing them tells an attacker nothing about a specific address. `assertUserIsInvitedToPlatformOrProject` reveals whether *that* address was invited, so an un-invited request returns the same 204 as success, sends nothing, and creates nothing. This mirrors the silent return `createAndSend` already uses for unknown emails. + +**Brute force is capped per credential, not per IP.** A 6-digit code is a 10^6 space, and `confirm` compared plaintext with no attempt counter while the request endpoint carried no rate-limit config. Rate limiting alone does not bound a distributed attacker, so the row now carries an `attempts` counter and dies on the fifth wrong guess. Rate limits go on both endpoints as well, but the counter is what makes the budget five guesses per issued code regardless of how the requests are spread. + +**Resend delivers the same code, it does not mint a new one.** One `TEN_MINUTES` constant served as both the expiry and the resend suppression, so `createAndSend` returned without sending until the existing code expired. That is a spam guard for a link and a ten minute lockout for a code that landed in spam. Resend now re-sends the existing value and leaves `updated` alone, so the original expiry still governs and both emails carry the same code. Minting a fresh code per resend was rejected because members type the first code they see, so reissuing invalidates the one half of them are already reading. + +**Verifying a code lands the member in the product, with no naming step.** Today a brand-new Cloud identity gets an ONBOARDING response and has to name its platform at `/create-platform` before it can do anything. The code path skips that: on Cloud, when the verified identity belongs to no platform, `verifyCode` creates one through `createPlatformWithProject` with a name derived from the email local part, and returns a full session. Renaming stays available in settings. This is Cloud-only by construction, because self-hosted sign-up takes the other `signUp` arm and joins the platform that already exists. It also means the passwordless path never mints an ONBOARDING principal; that window remains only for the password and federated paths. + +**The OTP module moves out of `ee/`.** Making `EMAIL_LOGIN` all-editions makes the primitive all-editions, so `ee/authentication/otp/` becomes `authentication/otp/` (four importers). This clears a standing `.claude/rules/edition-safety.md` violation rather than adding a second one, and it removes the trap the directory name set: the brain page already asserted "CE gets OTP flows" while the module was registered for Cloud and Enterprise only. A `hooksFactory` seam was rejected as one interface with one implementation around a primitive every edition now runs. + +## Consequences +An identity is created before ownership is proven, the member's name is a guess, and the resend window behaves differently. + +- **`firstName` is derived from the email local part, knowingly as a placeholder.** A code sign-up asks for no name, so the local part is capitalised and stored. This is wrong for shared and role addresses (`info@` becomes "Info") and for single-letter local parts, and the guess persists as the member's real name. Accepted deliberately to keep the first cut shippable, with the derivation to be replaced later rather than left to rot. Nothing else depends on it: platform naming derives from the local part directly, not from `firstName`. + +- `requestCode` creates a `UserIdentity` with `verified: false` and a random password when none exists, because the `otp` row needs an `identityId` to hang on. So an unauthenticated endpoint can create rows. `ApFlagId.USER_CREATED` is deliberately NOT set until a code verifies, and both endpoints are rate limited. Never-verified identities need a prune story. +- `confirm` now deletes the row instead of marking it `CONFIRMED`. This is also a bug fix for the two existing types: `updated` is an `updateDate` column, so marking the row refreshed it and the ten-minute guard then blocked that identity from requesting another code for ten minutes after a successful use. +- **The attempt counter is incremented with a bare `UPDATE ... SET attempts = attempts + 1 RETURNING attempts`, not through the repository.** Two reasons, both found in review. TypeORM's `update` touches `updated`, the very column the expiry and resend-suppression checks read, so counting a wrong guess would have extended the credential's life by ten minutes per guess. And a read-modify-write increment lets concurrent verifies write the same value, so the five-guess budget would never be reached under a parallel attack. +- **`OtpState.CONFIRMED` is now written by nothing.** It survives only as the `otpIsPending` read, which still correctly rejects legacy rows a previous build left CONFIRMED. Retire the member once those rows have aged past the ten-minute window. +- **The public `POST /v1/otp` route must never accept `EMAIL_LOGIN`.** Adding the member to `OtpType` silently widened that unauthenticated, unthrottled, unguarded route into a way to email anyone a working sign-in code, so `CreateOtpRequestBody` now narrows `type` to the two link types. +- **`verifyCode` re-asserts the platform auth policy on the resolved platform.** On Cloud, `getPlatformIdForRequest` returns null for unauthenticated requests, so the request-scoped branch never runs there; without a second assert an email code would sign a member into a platform that had deliberately disabled email auth or removed their domain from the allow-list. Deliberately not asserted at request time, since surfacing those errors per address would rebuild the existence oracle this design closes. +- An un-invited member on an invitation-only instance is told "check your email" and no mail arrives. Accepted in exchange for closing the invitation oracle. +- Adding an `OtpType` member is a forced compile break in `email-service.ts`, whose `frontendPath` is a two-key literal indexed by the full union. Its sibling `otpToTemplate` is typed `Record`, so it type-checks while handing `undefined` to the sender at runtime. Both are replaced by one exhaustive switch. diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index b5798233884..0a22cb332e0 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.134.0", + "version": "0.135.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/ee/otp/otp-model.ts b/packages/core/shared/src/lib/ee/otp/otp-model.ts index daded13e68a..c9175a17ffd 100644 --- a/packages/core/shared/src/lib/ee/otp/otp-model.ts +++ b/packages/core/shared/src/lib/ee/otp/otp-model.ts @@ -15,6 +15,7 @@ export const OtpModel = z.object({ identityId: ApId, value: z.string(), state: z.nativeEnum(OtpState), + attempts: z.number(), }) export type OtpModel = z.infer diff --git a/packages/core/shared/src/lib/ee/otp/otp-requests.ts b/packages/core/shared/src/lib/ee/otp/otp-requests.ts index 66d8ada6364..3edaba0ecee 100644 --- a/packages/core/shared/src/lib/ee/otp/otp-requests.ts +++ b/packages/core/shared/src/lib/ee/otp/otp-requests.ts @@ -4,7 +4,7 @@ import { OtpType } from './otp-type' export const CreateOtpRequestBody = z.object({ email: z.string(), - type: z.nativeEnum(OtpType), + type: z.enum([OtpType.EMAIL_VERIFICATION, OtpType.PASSWORD_RESET]), }) export type CreateOtpRequestBody = z.infer diff --git a/packages/core/shared/src/lib/ee/otp/otp-type.ts b/packages/core/shared/src/lib/ee/otp/otp-type.ts index 92fe0aa9e77..ccbb4ef895f 100644 --- a/packages/core/shared/src/lib/ee/otp/otp-type.ts +++ b/packages/core/shared/src/lib/ee/otp/otp-type.ts @@ -1,4 +1,5 @@ export enum OtpType { EMAIL_VERIFICATION = 'EMAIL_VERIFICATION', PASSWORD_RESET = 'PASSWORD_RESET', + EMAIL_LOGIN = 'EMAIL_LOGIN', } diff --git a/packages/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index f9dab5862fc..8df94295217 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -18,6 +18,7 @@ import { setPlatformOAuthService } from './app-connection/app-connection-service import { appConnectionModule } from './app-connection/app-connection.module' import { platformAppConnectionModule } from './app-connection/platform-app-connection.module' import { authenticationModule } from './authentication/authentication.module' +import { otpModule } from './authentication/otp/otp-module' import { canaryRoutingMiddleware } from './core/canary/canary-routing.middleware' import { collaborativeModule } from './core/collaborative/collaborative.module' import { oidcModule } from './core/security/oidc/oidc.module' @@ -36,7 +37,6 @@ import { appSumoModule } from './ee/appsumo/appsumo.module' import { auditEventModule } from './ee/audit-logs/audit-event-module' import { enterpriseLocalAuthnModule } from './ee/authentication/enterprise-local-authn/enterprise-local-authn-module' import { federatedAuthModule } from './ee/authentication/federated-authn/federated-authn-module' -import { otpModule } from './ee/authentication/otp/otp-module' import { rbacMiddleware } from './ee/authentication/project-role/rbac-middleware' import { authnSsoSamlModule } from './ee/authentication/saml-authn/authn-sso-saml-module' import { billingUsageReportModule } from './ee/billing-usage-report/billing-usage-report-module' @@ -384,6 +384,7 @@ export const setupApp = async (app: FastifyInstance): Promise = case ApEdition.COMMUNITY: await app.register(platformProjectModule) await app.register(communityPiecesModule) + await app.register(otpModule) break } diff --git a/packages/server/api/src/app/authentication/authentication.service.ts b/packages/server/api/src/app/authentication/authentication.service.ts index 379fe2f4898..38b50036d7a 100644 --- a/packages/server/api/src/app/authentication/authentication.service.ts +++ b/packages/server/api/src/app/authentication/authentication.service.ts @@ -2,7 +2,6 @@ import { ActivepiecesError, assertNotNullOrUndefined, ErrorCode, isNil } from '@ import { cryptoUtils } from '@activepieces/server-utils' import { ApEdition, ApEnvironment, ApFlagId, AuthenticationResponse, OtpType, PlatformWithoutSensitiveData, User, UserIdentity, UserIdentityProvider } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' -import { otpService } from '../ee/authentication/otp/otp-service' import { flagService } from '../flags/flag.service' import { system } from '../helper/system/system' import { AppSystemProp } from '../helper/system/system-props' @@ -10,6 +9,7 @@ import { platformService } from '../platform/platform.service' import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { authenticationUtils } from './authentication-utils' +import { otpService } from './otp/otp-service' import { userIdentityService } from './user-identity/user-identity-service' export const authenticationService = (log: FastifyBaseLogger) => ({ diff --git a/packages/server/api/src/app/authentication/otp/lib/otp-generator.ts b/packages/server/api/src/app/authentication/otp/lib/otp-generator.ts new file mode 100644 index 00000000000..1ef47c58e50 --- /dev/null +++ b/packages/server/api/src/app/authentication/otp/lib/otp-generator.ts @@ -0,0 +1,18 @@ +import { randomInt, randomUUID } from 'node:crypto' +import { OtpType } from '@activepieces/shared' + +export const otpGenerator = { + generate({ type }: GenerateParams): string { + if (type !== OtpType.EMAIL_LOGIN) { + return randomUUID() + } + const upperBound = 10 ** LOGIN_CODE_LENGTH + return randomInt(0, upperBound).toString().padStart(LOGIN_CODE_LENGTH, '0') + }, +} + +const LOGIN_CODE_LENGTH = 6 + +type GenerateParams = { + type: OtpType +} diff --git a/packages/server/api/src/app/ee/authentication/otp/otp-controller.ts b/packages/server/api/src/app/authentication/otp/otp-controller.ts similarity index 56% rename from packages/server/api/src/app/ee/authentication/otp/otp-controller.ts rename to packages/server/api/src/app/authentication/otp/otp-controller.ts index 5794d11321d..7230cf5b0d6 100644 --- a/packages/server/api/src/app/ee/authentication/otp/otp-controller.ts +++ b/packages/server/api/src/app/authentication/otp/otp-controller.ts @@ -1,11 +1,9 @@ import { CreateOtpRequestBody } from '@activepieces/shared' -import { RateLimitOptions } from '@fastify/rate-limit' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' -import { securityAccess } from '../../../core/security/authorization/fastify-security' -import { system } from '../../../helper/system/system' -import { AppSystemProp } from '../../../helper/system/system-props' -import { platformUtils } from '../../../platform/platform.utils' +import { securityAccess } from '../../core/security/authorization/fastify-security' +import { authnRateLimit } from '../../core/security/rate-limit' +import { platformUtils } from '../../platform/platform.utils' import { otpService } from './otp-service' export const otpController: FastifyPluginAsyncZod = async (app) => { @@ -20,18 +18,10 @@ export const otpController: FastifyPluginAsyncZod = async (app) => { }) } -const rateLimitOptions: RateLimitOptions = { - max: Number.parseInt( - system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_MAX), - 10, - ), - timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), -} - const CreateOtpRequest = { config: { security: securityAccess.public(), - rateLimit: rateLimitOptions, + rateLimit: authnRateLimit, }, schema: { body: CreateOtpRequestBody, diff --git a/packages/server/api/src/app/ee/authentication/otp/otp-entity.ts b/packages/server/api/src/app/authentication/otp/otp-entity.ts similarity index 89% rename from packages/server/api/src/app/ee/authentication/otp/otp-entity.ts rename to packages/server/api/src/app/authentication/otp/otp-entity.ts index 99d307c9b5c..35124eba68c 100644 --- a/packages/server/api/src/app/ee/authentication/otp/otp-entity.ts +++ b/packages/server/api/src/app/authentication/otp/otp-entity.ts @@ -3,7 +3,7 @@ import { EntitySchema } from 'typeorm' import { ApIdSchema, BaseColumnSchemaPart, -} from '../../../database/database-common' +} from '../../database/database-common' export type OtpSchema = OtpModel & { userIdentity: UserIdentity @@ -31,6 +31,11 @@ export const OtpEntity = new EntitySchema({ enum: OtpState, nullable: false, }, + attempts: { + type: Number, + nullable: false, + default: 0, + }, }, indices: [ { diff --git a/packages/server/api/src/app/ee/authentication/otp/otp-module.ts b/packages/server/api/src/app/authentication/otp/otp-module.ts similarity index 100% rename from packages/server/api/src/app/ee/authentication/otp/otp-module.ts rename to packages/server/api/src/app/authentication/otp/otp-module.ts diff --git a/packages/server/api/src/app/authentication/otp/otp-service.ts b/packages/server/api/src/app/authentication/otp/otp-service.ts new file mode 100644 index 00000000000..69ab2afb93b --- /dev/null +++ b/packages/server/api/src/app/authentication/otp/otp-service.ts @@ -0,0 +1,123 @@ +import { apId, isNil, PlatformId } from '@activepieces/core-utils' +import { OtpModel, OtpState, OtpType } from '@activepieces/shared' +import dayjs from 'dayjs' +import { FastifyBaseLogger } from 'fastify' +import { repoFactory } from '../../core/db/repo-factory' +import { distributedLock } from '../../database/redis-connections' +import { emailService } from '../../ee/helper/email/email-service' +import { userIdentityService } from '../user-identity/user-identity-service' +import { otpGenerator } from './lib/otp-generator' +import { OtpEntity } from './otp-entity' + +const OTP_EXPIRATION_MS: Record = { + [OtpType.EMAIL_VERIFICATION]: 24 * 60 * 60 * 1000, + [OtpType.PASSWORD_RESET]: 10 * 60 * 1000, + [OtpType.EMAIL_LOGIN]: 10 * 60 * 1000, +} +const MAX_ATTEMPTS = 5 + +const repo = repoFactory(OtpEntity) + +export const otpService = (log: FastifyBaseLogger) => ({ + async createAndSend({ + platformId, + email, + type, + }: CreateParams): Promise { + const userIdentity = await userIdentityService(log).getIdentityByEmail(email) + if (!userIdentity) { + return + } + const existingOtp = await repo().findOneBy({ + identityId: userIdentity.id, + type, + }) + const existingOtpIsReusable = !isNil(existingOtp) && existingOtp.state === OtpState.PENDING && !otpIsExpired(existingOtp) + if (existingOtpIsReusable) { + await emailService(log).sendOtp({ + platformId, + userIdentity, + otp: existingOtp.value, + type: existingOtp.type, + }) + return + } + const newOtp: Omit = { + id: apId(), + updated: dayjs().toISOString(), + type, + identityId: userIdentity.id, + value: otpGenerator.generate({ type }), + state: OtpState.PENDING, + attempts: 0, + } + await repo().upsert(newOtp, ['identityId', 'type']) + await emailService(log).sendOtp({ + platformId, + userIdentity, + otp: newOtp.value, + type: newOtp.type, + }) + }, + + async confirm({ identityId, type, value }: ConfirmParams): Promise { + return distributedLock(log).runExclusive({ + key: `otp-confirm-${identityId}-${type}`, + timeoutInSeconds: 15, + fn: async () => { + const otp = await repo().findOneBy({ identityId, type }) + if (isNil(otp)) { + return false + } + if (otp.attempts >= MAX_ATTEMPTS) { + await discard({ otp, identityId, type, log }) + return false + } + const otpIsPending = otp.state === OtpState.PENDING + const otpIsNotExpired = !otpIsExpired(otp) + const otpMatches = otp.value === value + if (otpIsNotExpired && otpMatches && otpIsPending) { + await repo().delete({ id: otp.id }) + return true + } + await countAttempt(otp.id) + if (otp.attempts + 1 >= MAX_ATTEMPTS) { + await discard({ otp, identityId, type, log }) + } + return false + }, + }) + }, +}) + +async function countAttempt(otpId: string): Promise { + await repo().query('UPDATE "otp" SET "attempts" = "attempts" + 1 WHERE "id" = $1', [otpId]) +} + +async function discard({ otp, identityId, type, log }: DiscardParams): Promise { + await repo().delete({ id: otp.id }) + log.warn({ identityId, type }, '[otpService#confirm] attempt budget exhausted, credential discarded') +} + +function otpIsExpired(otp: OtpModel): boolean { + return dayjs().diff(otp.updated, 'milliseconds') >= OTP_EXPIRATION_MS[otp.type] +} + +type CreateParams = { + platformId: PlatformId | null + email: string + type: OtpType +} + +type DiscardParams = { + otp: OtpModel + identityId: string + type: OtpType + log: FastifyBaseLogger +} + +type ConfirmParams = { + identityId: string + type: OtpType + value: string +} diff --git a/packages/server/api/src/app/core/security/rate-limit.ts b/packages/server/api/src/app/core/security/rate-limit.ts index 87629037b1d..34dc4cee6e2 100644 --- a/packages/server/api/src/app/core/security/rate-limit.ts +++ b/packages/server/api/src/app/core/security/rate-limit.ts @@ -1,4 +1,4 @@ -import RateLimitPlugin from '@fastify/rate-limit' +import RateLimitPlugin, { RateLimitOptions } from '@fastify/rate-limit' import FastifyPlugin from 'fastify-plugin' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { redisConnections } from '../../database/redis-connections' @@ -21,3 +21,11 @@ export const rateLimitModule: FastifyPluginAsyncZod = FastifyPlugin( } }, ) + +export const authnRateLimit: RateLimitOptions = { + max: Number.parseInt( + system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_MAX), + 10, + ), + timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), +} diff --git a/packages/server/api/src/app/database/database-connection.ts b/packages/server/api/src/app/database/database-connection.ts index 0ea4cddef07..65b8a21cce1 100644 --- a/packages/server/api/src/app/database/database-connection.ts +++ b/packages/server/api/src/app/database/database-connection.ts @@ -7,6 +7,7 @@ import { AIProviderEntity } from '../ai/ai-provider-entity' import { AiToolConfigEntity } from '../ai/ai-tool-config-entity' import { PlatformAnalyticsReportEntity } from '../analytics/platform-analytics-report.entity' import { AppConnectionEntity } from '../app-connection/app-connection.entity' +import { OtpEntity } from '../authentication/otp/otp-entity' import { UserIdentityEntity } from '../authentication/user-identity/user-identity-entity' import { AgentConversationEntity } from '../ee/agent/agent-conversation-entity' import { AgentEntity } from '../ee/agent/agent-entity' @@ -17,7 +18,6 @@ import { ApiKeyEntity } from '../ee/api-keys/api-key-entity' import { AppCredentialEntity } from '../ee/app-credentials/app-credentials.entity' import { AppSumoEntity } from '../ee/appsumo/appsumo.entity' import { AuditEventEntity } from '../ee/audit-logs/audit-event-entity' -import { OtpEntity } from '../ee/authentication/otp/otp-entity' import { ConnectionKeyEntity } from '../ee/connection-keys/connection-key.entity' import { EmbedSubdomainEntity } from '../ee/embed-subdomain/embed-subdomain.entity' import { OAuthAppEntity } from '../ee/oauth-apps/oauth-app.entity' diff --git a/packages/server/api/src/app/database/migration/postgres/1824000000000-AddAttemptsToOtp.ts b/packages/server/api/src/app/database/migration/postgres/1824000000000-AddAttemptsToOtp.ts new file mode 100644 index 00000000000..712d191f85c --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1824000000000-AddAttemptsToOtp.ts @@ -0,0 +1,21 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class AddAttemptsToOtp1824000000000 implements Migration { + name = 'AddAttemptsToOtp1824000000000' + breaking = false + release = '0.88.0' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "otp" + ADD "attempts" integer NOT NULL DEFAULT '0' + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "otp" DROP COLUMN "attempts" + `) + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 080651fc6d0..8f7cdb95e4f 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -414,6 +414,7 @@ import { AddAuditEventPlatformIdCreatedIdIndex1820000000000 } from './migration/ import { AddAgentConversationFlowStepRetentionIndex1821000000000 } from './migration/postgres/1821000000000-AddAgentConversationFlowStepRetentionIndex' import { RenameChatTablesToAgent1822000000000 } from './migration/postgres/1822000000000-RenameChatTablesToAgent' import { AddRenamedChatTableCompatViews1823000000000 } from './migration/postgres/1823000000000-AddRenamedChatTableCompatViews' +import { AddAttemptsToOtp1824000000000 } from './migration/postgres/1824000000000-AddAttemptsToOtp' import { AddAgentTable1825000000000 } from './migration/postgres/1825000000000-AddAgentTable' const getSslConfig = (): boolean | TlsOptions => { @@ -844,6 +845,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddAgentConversationFlowStepRetentionIndex1821000000000, RenameChatTablesToAgent1822000000000, AddRenamedChatTableCompatViews1823000000000, + AddAttemptsToOtp1824000000000, AddAgentTable1825000000000, ] return migrations diff --git a/packages/server/api/src/app/ee/authentication/enterprise-local-authn/enterprise-local-authn-service.ts b/packages/server/api/src/app/ee/authentication/enterprise-local-authn/enterprise-local-authn-service.ts index 4970961e7d4..2e243f9531a 100644 --- a/packages/server/api/src/app/ee/authentication/enterprise-local-authn/enterprise-local-authn-service.ts +++ b/packages/server/api/src/app/ee/authentication/enterprise-local-authn/enterprise-local-authn-service.ts @@ -1,10 +1,10 @@ import { ActivepiecesError, ErrorCode, isNil, UserId } from '@activepieces/core-utils' import { ApplicationEvent, ApplicationEventName, OtpType, ResetPasswordRequestBody, UserIdentity, VerifyEmailRequestBody } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { otpService } from '../../../authentication/otp/otp-service' import { userIdentityService } from '../../../authentication/user-identity/user-identity-service' import { applicationEvents } from '../../../helper/application-events' import { userService } from '../../../user/user-service' -import { otpService } from '../otp/otp-service' export const enterpriseLocalAuthnService = (log: FastifyBaseLogger) => ({ async verifyEmail({ identityId, otp }: VerifyEmailRequestBody): Promise { diff --git a/packages/server/api/src/app/ee/authentication/otp/lib/otp-generator.ts b/packages/server/api/src/app/ee/authentication/otp/lib/otp-generator.ts deleted file mode 100644 index 1d7b7b4edac..00000000000 --- a/packages/server/api/src/app/ee/authentication/otp/lib/otp-generator.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { randomUUID } from 'node:crypto' - -export const otpGenerator = { - generate(): string { - return randomUUID() - }, -} diff --git a/packages/server/api/src/app/ee/authentication/otp/otp-service.ts b/packages/server/api/src/app/ee/authentication/otp/otp-service.ts deleted file mode 100644 index aaa7759581c..00000000000 --- a/packages/server/api/src/app/ee/authentication/otp/otp-service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { apId, PlatformId } from '@activepieces/core-utils' -import { OtpModel, OtpState, OtpType } from '@activepieces/shared' -import dayjs from 'dayjs' -import { FastifyBaseLogger } from 'fastify' -import { userIdentityService } from '../../../authentication/user-identity/user-identity-service' -import { repoFactory } from '../../../core/db/repo-factory' -import { emailService } from '../../helper/email/email-service' -import { otpGenerator } from './lib/otp-generator' -import { OtpEntity } from './otp-entity' - -const OTP_EXPIRATION_MS: Record = { - [OtpType.EMAIL_VERIFICATION]: 24 * 60 * 60 * 1000, - [OtpType.PASSWORD_RESET]: 10 * 60 * 1000, -} - -const repo = repoFactory(OtpEntity) - -export const otpService = (log: FastifyBaseLogger) => ({ - async createAndSend({ - platformId, - email, - type, - }: CreateParams): Promise { - const userIdentity = await userIdentityService(log).getIdentityByEmail(email) - if (!userIdentity) { - return - } - const existingOtp = await repo().findOneBy({ - identityId: userIdentity.id, - type, - }) - const existingOtpIsReusable = existingOtp && existingOtp.state === OtpState.PENDING && !otpIsExpired(existingOtp) - if (existingOtpIsReusable) { - await emailService(log).sendOtp({ - platformId, - userIdentity, - otp: existingOtp.value, - type, - }) - return - } - const newOtp: Omit = { - id: apId(), - updated: dayjs().toISOString(), - type, - identityId: userIdentity.id, - value: otpGenerator.generate(), - state: OtpState.PENDING, - } - await repo().upsert(newOtp, ['identityId', 'type']) - await emailService(log).sendOtp({ - platformId, - userIdentity, - otp: newOtp.value, - type: newOtp.type, - }) - }, - - async confirm({ identityId, type, value }: ConfirmParams): Promise { - const otp = await repo().findOneByOrFail({ - identityId, - type, - }) - const otpIsPending = otp.state === OtpState.PENDING - const otpMatches = otp.value === value - const verdict = !otpIsExpired(otp) && otpMatches && otpIsPending - if (verdict) { - await repo().update(otp.id, { - state: OtpState.CONFIRMED, - }) - } - - return verdict - }, -}) - -function otpIsExpired(otp: OtpModel): boolean { - return dayjs().diff(otp.updated, 'milliseconds') >= OTP_EXPIRATION_MS[otp.type] -} - -type CreateParams = { - platformId: PlatformId | null - email: string - type: OtpType -} - -type ConfirmParams = { - identityId: string - type: OtpType - value: string -} diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts index 364529d9c3f..ff7e37d9613 100644 --- a/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts +++ b/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts @@ -66,6 +66,10 @@ type ScimUserWelcomeTemplateData = BaseEmailTemplateData<'scim-user-welcome', { loginLink: string }> +type LoginCodeTemplateData = BaseEmailTemplateData<'login-code', { + code: string +}> + type ChatNotificationTemplateData = BaseEmailTemplateData<'chat-notification', { subject: string body: string @@ -86,6 +90,7 @@ export type EmailTemplateData = | ScimUserWelcomeTemplateData | ChatNotificationTemplateData | PlatformDeletedTemplateData + | LoginCodeTemplateData type SendArgs = { emails: string[] diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts index 9124b5c954f..94218b55345 100644 --- a/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts +++ b/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts @@ -138,6 +138,7 @@ const getEmailSubject = (templateName: EmailTemplateData['name'], vars: Record ({ }, async sendOtp({ platformId, userIdentity, otp, type }: SendOtpArgs): Promise { - if (EDITION_IS_NOT_PAID) { + if (EDITION_IS_NOT_PAID && type !== OtpType.EMAIL_LOGIN) { return } @@ -181,34 +181,10 @@ export const emailService = (log: FastifyBaseLogger) => ({ type, }, 'Sending OTP email') - const frontendPath = { - [OtpType.EMAIL_VERIFICATION]: 'verify-email', - [OtpType.PASSWORD_RESET]: 'reset-password', - } - - const setupLink = await domainHelper.getInternalUrl({ - path: frontendPath[type] + `?otpcode=${otp}&identityId=${userIdentity.id}`, - }) - - const otpToTemplate: Record = { - [OtpType.EMAIL_VERIFICATION]: { - name: 'verify-email', - vars: { - setupLink, - }, - }, - [OtpType.PASSWORD_RESET]: { - name: 'reset-password', - vars: { - setupLink, - }, - }, - } - await emailSender(log).send({ emails: [userIdentity.email], platformId: platformId ?? undefined, - templateData: otpToTemplate[type], + templateData: await otpTemplateData({ type, otp, identityId: userIdentity.id }), }) }, @@ -236,6 +212,29 @@ export const emailService = (log: FastifyBaseLogger) => ({ }, }) +async function otpTemplateData({ type, otp, identityId }: OtpTemplateDataParams): Promise { + switch (type) { + case OtpType.EMAIL_LOGIN: + return { name: 'login-code', vars: { code: otp } } + case OtpType.EMAIL_VERIFICATION: + return { + name: 'verify-email', + vars: { setupLink: await otpSetupLink({ path: 'verify-email', otp, identityId }) }, + } + case OtpType.PASSWORD_RESET: + return { + name: 'reset-password', + vars: { setupLink: await otpSetupLink({ path: 'reset-password', otp, identityId }) }, + } + } +} + +async function otpSetupLink({ path, otp, identityId }: OtpSetupLinkParams): Promise { + return domainHelper.getInternalUrl({ + path: `${path}?otpcode=${otp}&identityId=${identityId}`, + }) +} + async function getEntityNameForInvitation(userInvitation: UserInvitation, log: FastifyBaseLogger): Promise<{ name: string, role: string }> { switch (userInvitation.type) { case InvitationType.PLATFORM: { @@ -274,6 +273,18 @@ type SendProjectMemberAddedArgs = { userInvitation: UserInvitation } +type OtpTemplateDataParams = { + type: OtpType + otp: string + identityId: string +} + +type OtpSetupLinkParams = { + path: string + otp: string + identityId: string +} + type SendOtpArgs = { type: OtpType platformId: string | null diff --git a/packages/server/api/src/assets/emails/login-code.html b/packages/server/api/src/assets/emails/login-code.html new file mode 100644 index 00000000000..b9a2328b599 --- /dev/null +++ b/packages/server/api/src/assets/emails/login-code.html @@ -0,0 +1,79 @@ + + + + + + + + + + + Your sign-in code 🔑 + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{platformName}} +
+ Your sign-in code 🔑 +
+ Enter this code to finish signing in to {{platformName}}. It expires in 10 minutes. +
+ + + + + + +
+ {{code}} +
+
+ If you didn't try to sign in, you can ignore this email. Nobody can access your account without this code. +
+ {{> footer}} +
+ +
+ + diff --git a/packages/server/api/test/integration/ce/authentication/otp-service.test.ts b/packages/server/api/test/integration/ce/authentication/otp-service.test.ts new file mode 100644 index 00000000000..539320446a9 --- /dev/null +++ b/packages/server/api/test/integration/ce/authentication/otp-service.test.ts @@ -0,0 +1,156 @@ +import { OtpType } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { otpService } from '../../../../src/app/authentication/otp/otp-service' +import { databaseConnection } from '../../../../src/app/database/database-connection' +import { createMockUserIdentity } from '../../../helpers/mocks' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance | null = null + +const EMAIL = 'otp.budget@example.com' +const MAX_ATTEMPTS = 5 + +async function seedIdentityWithCode(): Promise { + const identity = createMockUserIdentity({ email: EMAIL, verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + await otpService(app!.log).createAndSend({ + platformId: null, + email: EMAIL, + type: OtpType.EMAIL_LOGIN, + }) + const otp = await databaseConnection().getRepository('otp').findOneBy({ + identityId: identity.id, + type: OtpType.EMAIL_LOGIN, + }) + return otp!.value +} + +async function currentOtp() { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + return databaseConnection().getRepository('otp').findOneBy({ + identityId: identity!.id, + type: OtpType.EMAIL_LOGIN, + }) +} + +async function confirmCode(value: string): Promise { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + return otpService(app!.log).confirm({ + identityId: identity!.id, + type: OtpType.EMAIL_LOGIN, + value, + }) +} + +function wrongVersionOf(value: string): string { + const shifted = (Number.parseInt(value, 10) + 1) % 1000000 + return shifted.toString().padStart(6, '0') +} + +async function sendCode(): Promise { + await otpService(app!.log).createAndSend({ + platformId: null, + email: EMAIL, + type: OtpType.EMAIL_LOGIN, + }) +} + +async function backdateCode(minutesAgo: number): Promise { + const otp = await currentOtp() + const sentAt = new Date(Date.now() - minutesAgo * 60 * 1000) + await databaseConnection().getRepository('otp') + .query('UPDATE "otp" SET "updated" = $1 WHERE "id" = $2', [sentAt.toISOString(), otp!.id]) + return sentAt +} + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +beforeEach(async () => { + await databaseConnection().getRepository('otp').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user_identity').createQueryBuilder().delete().execute() +}) + +describe('otpService#createAndSend', () => { + it('re-sends the code already in flight instead of minting a second one', async () => { + const issued = await seedIdentityWithCode() + + await sendCode() + + expect((await currentOtp())!.value).toBe(issued) + }) + + it('mints a fresh code once the one in flight has expired', async () => { + const issued = await seedIdentityWithCode() + await backdateCode(11) + + await sendCode() + + expect((await currentOtp())!.value).not.toBe(issued) + }) +}) + +describe('otpService#confirm', () => { + it('accepts the correct code and consumes it', async () => { + const value = await seedIdentityWithCode() + + expect(await confirmCode(value)).toBe(true) + expect(await currentOtp()).toBeNull() + }) + + it('accepts the correct code exactly once', async () => { + const value = await seedIdentityWithCode() + await confirmCode(value) + + expect(await confirmCode(value)).toBe(false) + }) + + it('refuses a wrong code and spends one attempt', async () => { + const value = await seedIdentityWithCode() + + expect(await confirmCode(wrongVersionOf(value))).toBe(false) + expect((await currentOtp())!.attempts).toBe(1) + }) + + it('refuses a correct code once the attempt budget is already spent', async () => { + const value = await seedIdentityWithCode() + const otp = await currentOtp() + await databaseConnection().getRepository('otp').update(otp!.id, { attempts: MAX_ATTEMPTS }) + + const accepted = await confirmCode(value) + + expect(accepted).toBe(false) + }) + + it('throws the code away on the attempt that exhausts the budget', async () => { + const value = await seedIdentityWithCode() + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + await confirmCode(wrongVersionOf(value)) + } + + expect(await currentOtp()).toBeNull() + expect(await confirmCode(value)).toBe(false) + }) + + it('refuses a correct code that has outlived its ten minutes', async () => { + const value = await seedIdentityWithCode() + await backdateCode(11) + + expect(await confirmCode(value)).toBe(false) + }) + + it('does not extend the life of a code by guessing at it', async () => { + const value = await seedIdentityWithCode() + const backdated = await backdateCode(9) + + await confirmCode(wrongVersionOf(value)) + + expect(new Date((await currentOtp())!.updated).getTime()).toBe(backdated.getTime()) + }) +}) diff --git a/packages/server/api/test/integration/cloud/authn/enterprise-local-authn.test.ts b/packages/server/api/test/integration/cloud/authn/enterprise-local-authn.test.ts index 0bb20ca0b82..3b853f5e3e8 100644 --- a/packages/server/api/test/integration/cloud/authn/enterprise-local-authn.test.ts +++ b/packages/server/api/test/integration/cloud/authn/enterprise-local-authn.test.ts @@ -52,7 +52,7 @@ describe('Enterprise Local Authn API', () => { const userIdentity = await db.findOneBy('user_identity', { id: mockUserIdentity.id }) expect(userIdentity?.verified).toBe(true) const otp = await db.findOneBy('otp', { id: mockOtp.id }) - expect(otp?.state).toBe(OtpState.CONFIRMED) + expect(otp).toBeNull() }) it('Fails if OTP is wrong', async () => { diff --git a/packages/web/src/features/authentication/components/reset-password-form.tsx b/packages/web/src/features/authentication/components/reset-password-form.tsx index bd1960e5dfa..52238d47c38 100644 --- a/packages/web/src/features/authentication/components/reset-password-form.tsx +++ b/packages/web/src/features/authentication/components/reset-password-form.tsx @@ -24,7 +24,7 @@ import { HttpError } from '@/lib/api'; const FormSchema = z.object({ email: z.string().min(1, t('Please enter your email')), - type: z.nativeEnum(OtpType), + type: CreateOtpRequestBody.shape.type, }); type FormSchema = z.infer; From 3dd36d159831fb28afb520ffd26f405eb4df7219 Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:18:54 +0300 Subject: [PATCH 03/12] feat(auth): derive first, last and platform names from an email or full name (#14692) --- .../app/authentication/lib/signup-names.ts | 75 +++++++++++++++ .../app/authentication/signup-names.test.ts | 91 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 packages/server/api/src/app/authentication/lib/signup-names.ts create mode 100644 packages/server/api/test/unit/app/authentication/signup-names.test.ts diff --git a/packages/server/api/src/app/authentication/lib/signup-names.ts b/packages/server/api/src/app/authentication/lib/signup-names.ts new file mode 100644 index 00000000000..f34d16db82d --- /dev/null +++ b/packages/server/api/src/app/authentication/lib/signup-names.ts @@ -0,0 +1,75 @@ +import { isNil } from '@activepieces/core-utils' + +const MAX_NAME_PART_LENGTH = 50 +const FALLBACK_FIRST_NAME = 'there' +const PLATFORM_NAME_NOUN = 'Platform' +const FALLBACK_PLATFORM_NAME = 'My Platform' +const SAFE_STRING_CHARS = /[./]/g + +function localPartTokens(email: string): string[] { + const at = email.indexOf('@') + const localPart = at >= 0 ? email.slice(0, at) : email + return localPart + .split(/[._+-]+/) + .map((token) => token.replace(/[^a-zA-Z0-9]/g, '')) + .filter((token) => token.length > 0) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) +} + +function firstNameFromEmail(email: string): string { + const [first] = localPartTokens(email) + return first ?? FALLBACK_FIRST_NAME +} + +function platformNameFromPerson({ firstName, email }: PlatformNameFromPersonParams): string { + const [given] = firstName.replace(SAFE_STRING_CHARS, '').trim().split(/\s+/) + if (isNil(given) || given.length === 0) { + const [fromEmail] = localPartTokens(email) + return isNil(fromEmail) ? FALLBACK_PLATFORM_NAME : platformNameFor(fromEmail) + } + return platformNameFor(given) +} + +function platformNameFor(name: string): string { + return `${possessive(name.slice(0, MAX_NAME_PART_LENGTH))} ${PLATFORM_NAME_NOUN}` +} + +function possessive(name: string): string { + return /['’]s$/.test(name) ? name : `${name}'s` +} + +function splitFullName({ fullName, email }: SplitFullNameParams): SplitName { + const tokens = fullName + .split(/\s+/) + .map((token) => token.replace(SAFE_STRING_CHARS, '')) + .filter((token) => token.length > 0) + const [first, ...rest] = tokens + if (isNil(first)) { + return { firstName: firstNameFromEmail(email), lastName: '' } + } + return { + firstName: first.slice(0, MAX_NAME_PART_LENGTH), + lastName: rest.join(' ').slice(0, MAX_NAME_PART_LENGTH), + } +} + +export const signupNames = { + firstNameFromEmail, + platformNameFromPerson, + splitFullName, +} + +type PlatformNameFromPersonParams = { + firstName: string + email: string +} + +type SplitFullNameParams = { + fullName: string + email: string +} + +type SplitName = { + firstName: string + lastName: string +} diff --git a/packages/server/api/test/unit/app/authentication/signup-names.test.ts b/packages/server/api/test/unit/app/authentication/signup-names.test.ts new file mode 100644 index 00000000000..c6ec3dcdbdc --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/signup-names.test.ts @@ -0,0 +1,91 @@ +import { signupNames } from '../../../../src/app/authentication/lib/signup-names' + +describe('signupNames', () => { + describe('firstNameFromEmail', () => { + it.each([ + ['ahmad@activepieces.com', 'Ahmad'], + ['ahmad.tash@activepieces.com', 'Ahmad'], + ['ahmad_tash@activepieces.com', 'Ahmad'], + ['ahmad+work@activepieces.com', 'Ahmad'], + ['AHMAD@activepieces.com', 'AHMAD'], + ])('derives %s into %s', (email, expected) => { + expect(signupNames.firstNameFromEmail(email)).toBe(expected) + }) + + it('falls back when the local part carries no letters or digits', () => { + expect(signupNames.firstNameFromEmail('...@activepieces.com')).toBe('there') + }) + }) + + describe('splitFullName', () => { + it.each([ + ['Ahmad Tash', 'Ahmad', 'Tash'], + ['Ahmad', 'Ahmad', ''], + [' Ahmad Tash ', 'Ahmad', 'Tash'], + ['Ahmad Bin Tash', 'Ahmad', 'Bin Tash'], + ['ahmad tash', 'ahmad', 'tash'], + ])('splits %s into %s / %s', (fullName, firstName, lastName) => { + expect( + signupNames.splitFullName({ fullName, email: 'someone@activepieces.com' }), + ).toEqual({ firstName, lastName }) + }) + + it('strips the characters the platform name rule rejects', () => { + expect( + signupNames.splitFullName({ fullName: 'J. Smith', email: 'j@activepieces.com' }), + ).toEqual({ firstName: 'J', lastName: 'Smith' }) + }) + + it('falls back to the email when the name carries nothing usable', () => { + expect( + signupNames.splitFullName({ fullName: ' ', email: 'ahmad@activepieces.com' }), + ).toEqual({ firstName: 'Ahmad', lastName: '' }) + }) + }) + + describe('platformNameFromPerson', () => { + it.each([ + ['Ahmad', "Ahmad's Platform"], + ['Ahmad Bin', "Ahmad's Platform"], + ['Chris', "Chris's Platform"], + ["Ahmad's", "Ahmad's Platform"], + ])('names the platform from %s -> %s', (firstName, expected) => { + expect( + signupNames.platformNameFromPerson({ firstName, email: 'a.b@activepieces.com' }), + ).toBe(expected) + }) + + it('falls back to the email local part when the person has no usable name', () => { + expect( + signupNames.platformNameFromPerson({ firstName: '', email: 'ahmad.tash@activepieces.com' }), + ).toBe("Ahmad's Platform") + }) + + it('uses the whole fallback when neither the name nor the address yields a word', () => { + expect( + signupNames.platformNameFromPerson({ firstName: '', email: '___@activepieces.com' }), + ).toBe('My Platform') + }) + + it('stays inside the platform name limit when the address is one long word', () => { + const name = signupNames.platformNameFromPerson({ + firstName: '', + email: `${'a'.repeat(120)}@activepieces.com`, + }) + + expect(name.length).toBeLessThanOrEqual(100) + }) + + it('never produces a name the platform name rule rejects', () => { + const safeString = new RegExp('^[^./]+$') + const name = signupNames.platformNameFromPerson({ + firstName: 'J./Smith', + email: 'j@activepieces.com', + }) + + expect(name).toMatch(safeString) + expect(name.length).toBeLessThanOrEqual(100) + }) + }) + +}) From e43c4115da75028590dffa7043b32c15dd4f5baa Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:18:54 +0300 Subject: [PATCH 04/12] fix(auth): make first-platform creation single-use and self-healing (#14702) --- .../api/src/app/database/seeds/dev-seeds.ts | 2 + .../src/app/platform/platform.controller.ts | 5 +- .../api/src/app/platform/platform.service.ts | 201 +++++++++++++--- .../server/api/test/helpers/mocks/index.ts | 1 + .../first-platform-provisioning.test.ts | 226 ++++++++++++++++++ 5 files changed, 406 insertions(+), 29 deletions(-) create mode 100644 packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts diff --git a/packages/server/api/src/app/database/seeds/dev-seeds.ts b/packages/server/api/src/app/database/seeds/dev-seeds.ts index f207693a18b..f1616ee5e69 100644 --- a/packages/server/api/src/app/database/seeds/dev-seeds.ts +++ b/packages/server/api/src/app/database/seeds/dev-seeds.ts @@ -50,6 +50,8 @@ const seedDevUser = async (): Promise => { identityId: response.id, name: 'dev\'s Platform', invalidatePreviousTokens: true, + isFirstPlatform: true, + callerTokenVersion: undefined, }) log.info({ email: DEV_EMAIL, password: DEV_PASSWORD }, '[devSeeds#seedDevUser] Dev user and platform created') diff --git a/packages/server/api/src/app/platform/platform.controller.ts b/packages/server/api/src/app/platform/platform.controller.ts index 16db641b79c..8c4cd68b023 100644 --- a/packages/server/api/src/app/platform/platform.controller.ts +++ b/packages/server/api/src/app/platform/platform.controller.ts @@ -35,11 +35,14 @@ export const platformController: FastifyPluginAsyncZod = async (app) => { const identityId = isOnboarding ? req.principal.id : (await userService(req.log).getOneOrFail({ id: req.principal.id })).identityId - return platformService(req.log).createPlatformWithProject({ + const { response } = await platformService(req.log).createPlatformWithProject({ identityId, name: req.body.name, invalidatePreviousTokens: isOnboarding, + isFirstPlatform: isOnboarding, + callerTokenVersion: req.principal.type === PrincipalType.ONBOARDING ? req.principal.tokenVersion : undefined, }) + return response }) app.post('/:id', UpdatePlatformRequest, async (req, _res) => { diff --git a/packages/server/api/src/app/platform/platform.service.ts b/packages/server/api/src/app/platform/platform.service.ts index 46f8f6fe8df..203ade2455c 100644 --- a/packages/server/api/src/app/platform/platform.service.ts +++ b/packages/server/api/src/app/platform/platform.service.ts @@ -1,10 +1,11 @@ import { ActivepiecesError, apId, ErrorCode, isNil, PlatformId, spreadIfDefined, spreadIfNotUndefined, tryCatch, UserId } from '@activepieces/core-utils' -import { ApEdition, AuthenticationResponse, OPEN_SOURCE_PLAN, Platform, PlatformPlanLimits, PlatformRole, PlatformUsage, PlatformWithoutFederatedAuth, PlatformWithoutSensitiveData, ProjectType, SsoDomainVerification, SsoDomainVerificationStatus, UpdatePlatformRequestBody, UserStatus } from '@activepieces/shared' +import { ApEdition, AuthenticationResponse, OPEN_SOURCE_PLAN, Platform, PlatformPlanLimits, PlatformRole, PlatformUsage, PlatformWithoutFederatedAuth, PlatformWithoutSensitiveData, ProjectType, SsoDomainVerification, SsoDomainVerificationStatus, UpdatePlatformRequestBody, User, UserStatus } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { nanoid } from 'nanoid' import { authenticationUtils } from '../authentication/authentication-utils' import { userIdentityRepository, userIdentityService } from '../authentication/user-identity/user-identity-service' import { repoFactory } from '../core/db/repo-factory' +import { distributedLock } from '../database/redis-connections' import { invalidateSamlClientCache } from '../ee/authentication/saml-authn/saml-client' import { platformPlanService } from '../ee/platform/platform-plan/platform-plan.service' import { defaultTheme } from '../flags/theme' @@ -75,33 +76,49 @@ export const platformService = (log: FastifyBaseLogger) => ({ log.info({ platform: { id: savedPlatform.id }, ownerId }, 'Platform created') return stripFederatedAuth(savedPlatform) }, - async createPlatformWithProject({ identityId, name, invalidatePreviousTokens }: CreatePlatformWithProjectParams): Promise { - const newUser = await userService(log).create({ - identityId, - platformRole: PlatformRole.ADMIN, - platformId: null, - }) - const platform = await this.create({ ownerId: newUser.id, name }) - const defaultProject = await projectService(log).create({ - displayName: `${name}'s Project`, - ownerId: newUser.id, - platformId: platform.id, - type: ProjectType.PERSONAL, - }) - if (invalidatePreviousTokens) { - await userIdentityRepository().update(identityId, { - tokenVersion: nanoid(), - }) - } - await authenticationUtils(log).sendTelemetry({ - identity: await userIdentityService(log).getOneOrFail({ id: identityId }), - user: newUser, - projectId: defaultProject.id, - }) - return authenticationUtils(log).getProjectAndToken({ - userId: newUser.id, - platformId: platform.id, - projectId: defaultProject.id, + async createPlatformWithProject({ identityId, name, invalidatePreviousTokens, isFirstPlatform, callerTokenVersion, beforeProvision }: CreatePlatformWithProjectParams): Promise { + return distributedLock(log).runExclusive({ + key: `create-platform-${identityId}`, + timeoutInSeconds: 30, + fn: async () => { + const existingUsers = isFirstPlatform ? await userService(log).getByIdentityId({ identityId }) : [] + const provisionedOwner = findProvisionedOwner(existingUsers) + const platformAlreadyProvisioned = !isNil(provisionedOwner) + if (platformAlreadyProvisioned) { + return resumeProvisionedPlatform({ owner: provisionedOwner, identityId, name, invalidatePreviousTokens, callerTokenVersion, log }) + } + const ownerWithoutPlatform = existingUsers.find((user) => isNil(user.platformId)) + const unlinkedPlatform = isNil(ownerWithoutPlatform) ? null : await platformRepo().findOneBy({ ownerId: ownerWithoutPlatform.id }) + const provisioningStoppedBeforeLinkingTheOwner = !isNil(ownerWithoutPlatform) && !isNil(unlinkedPlatform) + if (provisioningStoppedBeforeLinkingTheOwner) { + await beforeProvision?.() + return linkOwnerToPlatform({ ownerId: ownerWithoutPlatform.id, platformId: unlinkedPlatform.id, identityId, name, invalidatePreviousTokens, log }) + } + await beforeProvision?.() + const owner = ownerWithoutPlatform + ?? await userService(log).create({ + identityId, + platformRole: PlatformRole.ADMIN, + platformId: null, + }) + const platform = await this.create({ ownerId: owner.id, name }) + const personalProject = await projectService(log).create({ + displayName: personalProjectName(name), + ownerId: owner.id, + platformId: platform.id, + type: ProjectType.PERSONAL, + }) + if (invalidatePreviousTokens) { + await rotateTokenVersion(identityId) + } + await reportSignup({ identityId, user: owner, projectId: personalProject.id, log }) + const response = await authenticationUtils(log).getProjectAndToken({ + userId: owner.id, + platformId: platform.id, + projectId: personalProject.id, + }) + return { response, provisioned: true } + }, }) }, async getAll(): Promise { @@ -251,6 +268,92 @@ export const platformService = (log: FastifyBaseLogger) => ({ }, }) +function findProvisionedOwner(users: User[]): PlatformOwner | undefined { + return users.find((user): user is PlatformOwner => !isNil(user.platformId)) +} + +async function resumeProvisionedPlatform({ owner, identityId, name, invalidatePreviousTokens, callerTokenVersion, log }: ResumeProvisionedPlatformParams): Promise { + const identity = await userIdentityService(log).getOneOrFail({ id: identityId }) + const earlierAttemptNeverRotated = isSameTokenVersion(identity.tokenVersion, callerTokenVersion) + const response = await finishExistingPlatform({ + user: owner, + platformId: owner.platformId, + name, + invalidatePreviousTokens: invalidatePreviousTokens && earlierAttemptNeverRotated, + identityId, + log, + }) + return { response, provisioned: false } +} + +async function linkOwnerToPlatform({ ownerId, platformId, identityId, name, invalidatePreviousTokens, log }: LinkOwnerToPlatformParams): Promise { + await userService(log).addOwnerToPlatform({ id: ownerId, platformId }) + const owner = await userService(log).getOneOrFail({ id: ownerId }) + const response = await finishExistingPlatform({ + user: owner, + platformId, + name, + invalidatePreviousTokens, + identityId, + log, + }) + if (!isNil(response.projectId)) { + await reportSignup({ identityId, user: owner, projectId: response.projectId, log }) + } + return { response, provisioned: true } +} + +async function reportSignup({ identityId, user, projectId, log }: ReportSignupParams): Promise { + await authenticationUtils(log).sendTelemetry({ + identity: await userIdentityService(log).getOneOrFail({ id: identityId }), + user, + projectId, + }) +} + +function isSameTokenVersion(current: string | undefined, caller: string | undefined): boolean { + const neitherHasBeenRotated = isNil(current) && isNil(caller) + return neitherHasBeenRotated || current === caller +} + +async function rotateTokenVersion(identityId: string): Promise { + await userIdentityRepository().update(identityId, { + tokenVersion: nanoid(), + }) +} + +function personalProjectName(platformName: string): string { + const noun = ' Platform' + if (platformName.endsWith(noun)) { + return `${platformName.slice(0, -noun.length)} Project` + } + return /['’]s$/.test(platformName) ? `${platformName} Project` : `${platformName}'s Project` +} + +async function finishExistingPlatform({ user, platformId, name, invalidatePreviousTokens, identityId, log }: FinishExistingPlatformParams): Promise { + const hasProjects = await projectService(log).userHasProjects({ + platformId, + userId: user.id, + isPrivileged: userService(log).isUserPrivileged(user), + }) + const project = hasProjects + ? null + : await projectService(log).create({ + displayName: personalProjectName(name), + ownerId: user.id, + platformId, + type: ProjectType.PERSONAL, + }) + if (invalidatePreviousTokens) { + await rotateTokenVersion(identityId) + } + return authenticationUtils(log).getProjectAndToken({ + userId: user.id, + platformId, + projectId: project?.id ?? null, + }) +} + async function getUsage(log: FastifyBaseLogger, platform: PlatformWithoutFederatedAuth): Promise { const edition = system.getEdition() if (edition === ApEdition.COMMUNITY) { @@ -311,10 +414,52 @@ type UpdateParams = UpdatePlatformRequestBody & { ssoDomainVerification?: SsoDomainVerification | null } +type CreatePlatformWithProjectResult = { + response: AuthenticationResponse + provisioned: boolean +} + type CreatePlatformWithProjectParams = { identityId: string name: string invalidatePreviousTokens: boolean + isFirstPlatform: boolean + callerTokenVersion: string | undefined + beforeProvision?: () => Promise +} + +type PlatformOwner = User & { + platformId: PlatformId +} +type ResumeProvisionedPlatformParams = { + owner: PlatformOwner + identityId: string + name: string + invalidatePreviousTokens: boolean + callerTokenVersion: string | undefined + log: FastifyBaseLogger +} +type LinkOwnerToPlatformParams = { + ownerId: UserId + platformId: PlatformId + identityId: string + name: string + invalidatePreviousTokens: boolean + log: FastifyBaseLogger +} +type FinishExistingPlatformParams = { + user: User + platformId: PlatformId + name: string + invalidatePreviousTokens: boolean + identityId: string + log: FastifyBaseLogger +} +type ReportSignupParams = { + identityId: string + user: User + projectId: string + log: FastifyBaseLogger } type ListPlatformsForIdentityParams = { diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index 0aa08a5303d..e5ffb7bc263 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -367,6 +367,7 @@ export const createMockOtp = (otp?: Partial): OtpModel => { value: otp?.value ?? faker.number.int({ min: 100000, max: 999999 }).toString(), state: otp?.state ?? faker.helpers.enumValue(OtpState), + attempts: otp?.attempts ?? 0, } } diff --git a/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts b/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts new file mode 100644 index 00000000000..22d4c51e80b --- /dev/null +++ b/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts @@ -0,0 +1,226 @@ +import { apId } from '@activepieces/core-utils' +import { PlatformRole, TelemetryEventName, UserStatus } from '@activepieces/shared' +import { FastifyBaseLogger, FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { authenticationUtils } from '../../../../src/app/authentication/authentication-utils' +import { databaseConnection } from '../../../../src/app/database/database-connection' +import { platformService } from '../../../../src/app/platform/platform.service' +import { createMockPlatform, createMockUserIdentity } from '../../../helpers/mocks' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +const trackProject = vi.fn() + +vi.mock('../../../../src/app/helper/telemetry.utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + telemetry: (log: FastifyBaseLogger) => ({ ...actual.telemetry(log), trackProject }), + } +}) + +let app: FastifyInstance | null = null + +const EMAIL = 'first.platform@example.com' + +async function seedVerifiedIdentity(): Promise { + const identity = createMockUserIdentity({ email: EMAIL, verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + return identity.id +} + +async function onboardingToken(identityId: string): Promise { + const response = await authenticationUtils(app!.log).getOnboardingResponse({ identityId }) + return response.token +} + +async function createViaRoute({ token, name }: { token: string, name: string }) { + return app?.inject({ + method: 'POST', + url: '/api/v1/platforms', + headers: { authorization: `Bearer ${token}` }, + body: { name }, + }) +} + +async function createFirstPlatform(identityId: string, callerTokenVersion?: string) { + const { response } = await platformService(app!.log).createPlatformWithProject({ + identityId, + name: 'Ahmad', + invalidatePreviousTokens: true, + isFirstPlatform: true, + callerTokenVersion, + }) + return response +} + +function provisionFirstPlatform(identityId: string) { + return platformService(app!.log).createPlatformWithProject({ + identityId, + name: 'Ahmad', + invalidatePreviousTokens: true, + isFirstPlatform: true, + callerTokenVersion: undefined, + }) +} + +async function tokenVersionOf(identityId: string): Promise { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ id: identityId }) + return identity!.tokenVersion +} + +async function strandUser(identityId: string): Promise { + const userId = apId() + await databaseConnection().getRepository('user').save({ + id: userId, + identityId, + platformId: null, + platformRole: PlatformRole.ADMIN, + status: UserStatus.ACTIVE, + }) + return userId +} + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +beforeEach(async () => { + trackProject.mockClear() + await databaseConnection().getRepository('project').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('platform').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user_identity').createQueryBuilder().delete().execute() +}) + +describe('First platform provisioning', () => { + it('gives one identity a single platform however many times it asks', async () => { + const identityId = await seedVerifiedIdentity() + + const first = await createFirstPlatform(identityId) + const second = await createFirstPlatform(identityId) + + expect(second.platformId).toBe(first.platformId) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + expect(await databaseConnection().getRepository('project').count()).toBe(1) + expect(await databaseConnection().getRepository('user').count()).toBe(1) + }) + + it('tells exactly one of two racing callers that it provisioned the platform', async () => { + const identityId = await seedVerifiedIdentity() + + const results = await Promise.all([ + provisionFirstPlatform(identityId), + provisionFirstPlatform(identityId), + ]) + + expect(results.filter((result) => result.provisioned)).toHaveLength(1) + }) + + it('reuses a user left unlinked by an interrupted attempt instead of creating a second one', async () => { + const identityId = await seedVerifiedIdentity() + await strandUser(identityId) + + await createFirstPlatform(identityId) + + expect(await databaseConnection().getRepository('user').count()).toBe(1) + }) + + it('adopts a platform whose owner link never landed instead of building a second one', async () => { + const identityId = await seedVerifiedIdentity() + const strandedUserId = await strandUser(identityId) + await databaseConnection().getRepository('platform').save( + createMockPlatform({ ownerId: strandedUserId }), + ) + + const response = await createFirstPlatform(identityId) + + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + expect(await databaseConnection().getRepository('user').count()).toBe(1) + const relinked = await databaseConnection().getRepository('user').findOneBy({ id: strandedUserId }) + expect(relinked?.platformId).toBe(response.platformId) + }) + + it('reports the signup it finished for a platform whose owner link never landed', async () => { + const identityId = await seedVerifiedIdentity() + const strandedUserId = await strandUser(identityId) + await databaseConnection().getRepository('platform').save( + createMockPlatform({ ownerId: strandedUserId }), + ) + + const response = await createFirstPlatform(identityId) + + const signedUp = trackProject.mock.calls.filter(([, event]) => event.name === TelemetryEventName.SIGNED_UP) + expect(signedUp).toHaveLength(1) + expect(signedUp[0][0]).toBe(response.projectId) + }) + + it('repairs a platform left without a project instead of wedging the identity', async () => { + const identityId = await seedVerifiedIdentity() + const first = await createFirstPlatform(identityId) + await databaseConnection().getRepository('project').createQueryBuilder().delete().execute() + + const retry = await createFirstPlatform(identityId) + + expect(retry.platformId).toBe(first.platformId) + expect(await databaseConnection().getRepository('project').count()).toBe(1) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + }) + + it('finishes the rotation an interrupted attempt never got to', async () => { + const identityId = await seedVerifiedIdentity() + const strandedUserId = await strandUser(identityId) + await databaseConnection().getRepository('platform').save( + createMockPlatform({ ownerId: strandedUserId }), + ) + await databaseConnection().getRepository('user') + .update(strandedUserId, { platformId: (await databaseConnection().getRepository('platform').findOneBy({ ownerId: strandedUserId }))!.id }) + const beforeRetry = await tokenVersionOf(identityId) + + await createFirstPlatform(identityId, beforeRetry) + + expect(await tokenVersionOf(identityId)).not.toBe(beforeRetry) + }) + + it('leaves the token version alone for a duplicate that carries a spent version', async () => { + const identityId = await seedVerifiedIdentity() + await createFirstPlatform(identityId, await tokenVersionOf(identityId)) + const afterFirst = await tokenVersionOf(identityId) + + await createFirstPlatform(identityId, 'a-version-from-before-the-rotation') + + expect(await tokenVersionOf(identityId)).toBe(afterFirst) + }) + + it('rotates once when two first-platform creations race, so neither session is stranded', async () => { + const identityId = await seedVerifiedIdentity() + + const [first, second] = await Promise.all([ + createFirstPlatform(identityId), + createFirstPlatform(identityId), + ]) + + const after = await databaseConnection().getRepository('user_identity').findOneBy({ id: identityId }) + const versionOf = (token: string) => + JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).tokenVersion + expect(versionOf(first.token)).toBe(after?.tokenVersion) + expect(versionOf(second.token)).toBe(after?.tokenVersion) + }) + + it('serves the onboarding route without provisioning a second platform', async () => { + const identityId = await seedVerifiedIdentity() + const token = await onboardingToken(identityId) + + const created = await createViaRoute({ token, name: 'Ahmad' }) + + expect(created?.statusCode).toBe(StatusCodes.OK) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ id: identityId }) + expect(identity?.tokenVersion).not.toBe( + JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).tokenVersion, + ) + }) +}) From 8ad513e607bb4df78e1d8e254b20e087ecd9d6dc Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:18:55 +0300 Subject: [PATCH 05/12] feat(auth): sign in with an emailed code (#14703) --- packages/core/shared/src/index.ts | 1 + .../dto/passwordless-request.ts | 23 ++ .../shared/src/lib/core/common/telemetry.ts | 18 ++ .../authentication.controller.ts | 79 +++++- .../authentication/authentication.service.ts | 7 + .../passwordless-auth.service.ts | 157 ++++++++++++ .../user-identity/user-identity-service.ts | 33 +++ .../api/src/app/core/security/rate-limit.ts | 8 + .../api/src/app/helper/system-validator.ts | 1 + .../api/src/app/helper/system/system-props.ts | 1 + .../api/src/app/helper/system/system.ts | 1 + .../api/src/app/helper/telemetry.utils.ts | 3 + .../authentication/passwordless-authn.test.ts | 233 ++++++++++++++++++ 13 files changed, 552 insertions(+), 13 deletions(-) create mode 100644 packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts create mode 100644 packages/server/api/src/app/authentication/passwordless-auth.service.ts create mode 100644 packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts diff --git a/packages/core/shared/src/index.ts b/packages/core/shared/src/index.ts index 9db20bad86f..6480cfb3dfa 100755 --- a/packages/core/shared/src/index.ts +++ b/packages/core/shared/src/index.ts @@ -3,6 +3,7 @@ export * from './lib/core/common/telemetry-pii' export * from './lib/core/authentication/dto/authentication-response' export * from './lib/core/authentication/dto/sign-up-request' export * from './lib/core/authentication/dto/sign-in-request' +export * from './lib/core/authentication/dto/passwordless-request' export * from './lib/core/authentication/model/principal-type' export * from './lib/core/authentication/model/principal' export * from './lib/core/authentication/user-identity' diff --git a/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts b/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts new file mode 100644 index 00000000000..a884ce6e487 --- /dev/null +++ b/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { EmailType } from '../../user/user' + +export const MAX_FULL_NAME_LENGTH = 100 + +export const RequestEmailCodeRequest = z.object({ + email: EmailType, +}) + +export type RequestEmailCodeRequest = z.infer + +export const VerifyEmailCodeRequest = z.object({ + email: EmailType, + code: z.string().trim().min(1), +}) + +export type VerifyEmailCodeRequest = z.infer + +export const CompleteSignUpRequest = z.object({ + fullName: z.string().trim().min(1).max(MAX_FULL_NAME_LENGTH), +}) + +export type CompleteSignUpRequest = z.infer diff --git a/packages/core/shared/src/lib/core/common/telemetry.ts b/packages/core/shared/src/lib/core/common/telemetry.ts index d4c880232f7..fb6eab2cd1c 100644 --- a/packages/core/shared/src/lib/core/common/telemetry.ts +++ b/packages/core/shared/src/lib/core/common/telemetry.ts @@ -35,6 +35,14 @@ type SignedUp = { projectId: ProjectId } +type EmailCodeRequested = { + isNewIdentity: boolean +} + +type EmailCodeVerified = { + needsNameStep: boolean +} + type QuotaAlert = { percentageUsed: number } @@ -188,6 +196,8 @@ type SignedIn = { } export enum TelemetryEventName { SIGNED_UP = 'signed.up', + EMAIL_CODE_REQUESTED = 'email.code.requested', + EMAIL_CODE_VERIFIED = 'email.code.verified', QUOTA_ALERT = 'quota.alert', REQUEST_TRIAL_CLICKED = 'request.trial.clicked', REQUEST_TRIAL_SUBMITTED = 'request.trial.submitted', @@ -239,6 +249,14 @@ type BaseTelemetryEvent = { export type TelemetryEvent = | BaseTelemetryEvent + | BaseTelemetryEvent< + TelemetryEventName.EMAIL_CODE_REQUESTED, + EmailCodeRequested + > + | BaseTelemetryEvent< + TelemetryEventName.EMAIL_CODE_VERIFIED, + EmailCodeVerified + > | BaseTelemetryEvent | BaseTelemetryEvent< TelemetryEventName.REQUEST_TRIAL_CLICKED, diff --git a/packages/server/api/src/app/authentication/authentication.controller.ts b/packages/server/api/src/app/authentication/authentication.controller.ts index ca1ed4b1e9b..b87e48553bf 100644 --- a/packages/server/api/src/app/authentication/authentication.controller.ts +++ b/packages/server/api/src/app/authentication/authentication.controller.ts @@ -1,8 +1,9 @@ import { isNil } from '@activepieces/core-utils' -import { ApplicationEventName, PrincipalType, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider } from '@activepieces/shared' -import { RateLimitOptions } from '@fastify/rate-limit' +import { ApplicationEventName, PrincipalType, RequestEmailCodeRequest, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider, VerifyEmailCodeRequest } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' +import { StatusCodes } from 'http-status-codes' import { securityAccess } from '../core/security/authorization/fastify-security' +import { authnRateLimit, emailCodeRateLimit } from '../core/security/rate-limit' import { applicationEvents } from '../helper/application-events' import { networkUtils } from '../helper/network-utils' import { rejectedPromiseHandler } from '../helper/promise-handler' @@ -12,6 +13,7 @@ import { telemetry } from '../helper/telemetry.utils' import { platformUtils } from '../platform/platform.utils' import { userService } from '../user/user-service' import { authenticationService } from './authentication.service' +import { passwordlessAuthService } from './passwordless-auth.service' export const authenticationController: FastifyPluginAsyncZod = async ( app, @@ -73,6 +75,45 @@ export const authenticationController: FastifyPluginAsyncZod = async ( return response }) + app.post('/otp/request', RequestEmailCodeRequestOptions, async (request, reply) => { + const platformId = await platformUtils.getPlatformIdForRequest(request) + await passwordlessAuthService(request.log).requestCode({ + email: request.body.email, + platformId: platformId ?? null, + }) + return reply.code(StatusCodes.NO_CONTENT).send() + }) + + app.post('/otp/verify', VerifyEmailCodeRequestOptions, async (request) => { + const platformId = await platformUtils.getPlatformIdForRequest(request) + const response = await passwordlessAuthService(request.log).verifyCode({ + email: request.body.email, + code: request.body.code, + platformId: platformId ?? null, + }) + + if (!isNil(response.platformId)) { + applicationEvents(request.log).sendUserEvent({ + platformId: response.platformId, + userId: response.id, + projectId: response.projectId ?? undefined, + ip: networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), + }, { + action: ApplicationEventName.USER_SIGNED_IN, + data: {}, + }) + rejectedPromiseHandler(telemetry(request.log).trackUser(response.id, { + name: TelemetryEventName.SIGNED_IN, + payload: { + userId: response.id, + platformId: response.platformId, + }, + }, { platform: response.platformId }), request.log) + } + + return response + }) + app.post('/switch-platform', SwitchPlatformRequestOptions, async (request) => { const user = await userService(request.log).getOneOrFail({ id: request.principal.id }) return authenticationService(request.log).switchPlatform({ @@ -83,20 +124,12 @@ export const authenticationController: FastifyPluginAsyncZod = async ( } -const rateLimitOptions: RateLimitOptions = { - max: Number.parseInt( - system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_MAX), - 10, - ), - timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), -} - const SwitchPlatformRequestOptions = { config: { security: securityAccess.publicPlatform([PrincipalType.USER]), - rateLimit: rateLimitOptions, + rateLimit: authnRateLimit, }, schema: { body: SwitchPlatformRequest, @@ -106,17 +139,37 @@ const SwitchPlatformRequestOptions = { const SignUpRequestOptions = { config: { security: securityAccess.public(), - rateLimit: rateLimitOptions, + rateLimit: authnRateLimit, }, schema: { body: SignUpRequest, }, } +const RequestEmailCodeRequestOptions = { + config: { + security: securityAccess.public(), + rateLimit: emailCodeRateLimit, + }, + schema: { + body: RequestEmailCodeRequest, + }, +} + +const VerifyEmailCodeRequestOptions = { + config: { + security: securityAccess.public(), + rateLimit: authnRateLimit, + }, + schema: { + body: VerifyEmailCodeRequest, + }, +} + const SignInRequestOptions = { config: { security: securityAccess.public(), - rateLimit: rateLimitOptions, + rateLimit: authnRateLimit, }, schema: { body: SignInRequest, diff --git a/packages/server/api/src/app/authentication/authentication.service.ts b/packages/server/api/src/app/authentication/authentication.service.ts index 38b50036d7a..366536d2b24 100644 --- a/packages/server/api/src/app/authentication/authentication.service.ts +++ b/packages/server/api/src/app/authentication/authentication.service.ts @@ -108,6 +108,9 @@ export const authenticationService = (log: FastifyBaseLogger) => ({ projectId: null, }) }, + async resolvePreferredPlatformId({ identityId }: ResolvePreferredPlatformIdParams): Promise { + return getPreferredPlatformId(identityId, log) + }, async federatedAuthn(params: FederatedAuthnParams): Promise { const platformId = isNil(params.predefinedPlatformId) ? await getPreferredPlatformIdForFederatedAuthn(params.email, log) : params.predefinedPlatformId const userIdentity = await userIdentityService(log).getIdentityByEmail(params.email) @@ -249,6 +252,10 @@ async function getPreferredPlatformId(identityId: string, log: FastifyBaseLogger +type ResolvePreferredPlatformIdParams = { + identityId: string +} + type FederatedAuthnParams = { email: string firstName: string diff --git a/packages/server/api/src/app/authentication/passwordless-auth.service.ts b/packages/server/api/src/app/authentication/passwordless-auth.service.ts new file mode 100644 index 00000000000..c4b26fa8776 --- /dev/null +++ b/packages/server/api/src/app/authentication/passwordless-auth.service.ts @@ -0,0 +1,157 @@ +import { ActivepiecesError, ErrorCode, isNil } from '@activepieces/core-utils' +import { cryptoUtils } from '@activepieces/server-utils' +import { ApFlagId, AuthenticationResponse, OtpType, TelemetryEventName, UserIdentity, UserIdentityProvider } from '@activepieces/shared' +import { FastifyBaseLogger } from 'fastify' +import { flagService } from '../flags/flag.service' +import { rejectedPromiseHandler } from '../helper/promise-handler' +import { system } from '../helper/system/system' +import { AppSystemProp } from '../helper/system/system-props' +import { telemetry } from '../helper/telemetry.utils' +import { userService } from '../user/user-service' +import { userInvitationsService } from '../user-invitations/user-invitation.service' +import { authenticationUtils } from './authentication-utils' +import { authenticationService } from './authentication.service' +import { signupNames } from './lib/signup-names' +import { otpService } from './otp/otp-service' +import { userIdentityService } from './user-identity/user-identity-service' + +export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ + async requestCode({ email, platformId }: RequestCodeParams): Promise { + const existingIdentity = await userIdentityService(log).getIdentityByEmail(email) + if (!isNil(platformId)) { + await assertPlatformAuthIsOpenTo({ email, platformId, log }) + const mayJoin = await mayJoinPlatform({ email, platformId, identity: existingIdentity, log }) + if (!mayJoin) { + return + } + } + if (isNil(existingIdentity)) { + await userIdentityService(log).create({ + email, + password: await cryptoUtils.generateRandomPassword(), + firstName: signupNames.firstNameFromEmail(email), + lastName: '', + trackEvents: true, + newsLetter: false, + provider: UserIdentityProvider.EMAIL, + verified: false, + }) + } + await otpService(log).createAndSend({ + platformId, + email, + type: OtpType.EMAIL_LOGIN, + }) + const identity = await userIdentityService(log).getIdentityByEmail(email) + if (!isNil(identity)) { + rejectedPromiseHandler(telemetry(log).trackIdentity(identity.id, { + name: TelemetryEventName.EMAIL_CODE_REQUESTED, + payload: { isNewIdentity: isNil(existingIdentity) }, + }), log) + } + }, + + async verifyCode({ email, code, platformId }: VerifyCodeParams): Promise { + const identity = await userIdentityService(log).getIdentityByEmail(email) + if (isNil(identity)) { + throw new ActivepiecesError({ code: ErrorCode.INVALID_OTP, params: {} }) + } + if (!isNil(platformId)) { + await assertPlatformAuthIsOpenTo({ email, platformId, log }) + } + const codeIsValid = await otpService(log).confirm({ + identityId: identity.id, + type: OtpType.EMAIL_LOGIN, + value: code, + }) + if (!codeIsValid) { + throw new ActivepiecesError({ code: ErrorCode.INVALID_OTP, params: {} }) + } + const verifiedIdentity = identity.verified ? identity : await userIdentityService(log).verifyAndDiscardPassword(identity.id) + await flagService(log).save({ id: ApFlagId.USER_CREATED, value: true }) + + const preferredPlatformId = isNil(platformId) + ? await authenticationService(log).resolvePreferredPlatformId({ identityId: verifiedIdentity.id }) + : platformId + rejectedPromiseHandler(telemetry(log).trackIdentity(verifiedIdentity.id, { + name: TelemetryEventName.EMAIL_CODE_VERIFIED, + payload: { needsNameStep: isNil(preferredPlatformId) }, + }), log) + + if (!isNil(platformId)) { + const mayJoin = await mayJoinPlatform({ email, platformId, identity: verifiedIdentity, log }) + if (!mayJoin) { + throw new ActivepiecesError({ + code: ErrorCode.INVITATION_ONLY_SIGN_UP, + params: { message: 'User is not invited to the platform' }, + }) + } + const user = await userService(log).getOrCreateWithProject({ + identity: verifiedIdentity, + platformId, + }) + await userInvitationsService(log).provisionUserInvitation({ email }) + return authenticationUtils(log).getProjectAndToken({ + userId: user.id, + platformId, + projectId: null, + }) + } + + if (!isNil(preferredPlatformId)) { + await assertPlatformAuthIsOpenTo({ email, platformId: preferredPlatformId, log }) + const user = await userService(log).getOrCreateWithProject({ + identity: verifiedIdentity, + platformId: preferredPlatformId, + }) + return authenticationUtils(log).getProjectAndToken({ + userId: user.id, + platformId: preferredPlatformId, + projectId: null, + }) + } + return authenticationUtils(log).getOnboardingResponse({ identityId: verifiedIdentity.id }) + }, + +}) + +async function assertPlatformAuthIsOpenTo({ email, platformId, log }: PlatformGateParams): Promise { + await authenticationUtils(log).assertEmailAuthIsEnabled({ + platformId, + provider: UserIdentityProvider.EMAIL, + }) + await authenticationUtils(log).assertDomainIsAllowed({ email, platformId }) +} + +async function mayJoinPlatform({ email, platformId, identity, log }: MayJoinPlatformParams): Promise { + if (system.get(AppSystemProp.ALLOW_OPEN_SIGN_UP) === 'true') { + return true + } + const isExistingMember = !isNil(identity) + && !isNil(await userService(log).getOneByIdentityAndPlatform({ identityId: identity.id, platformId })) + if (isExistingMember) { + return true + } + return userInvitationsService(log).hasAnyAcceptedInvitations({ platformId, email }) +} + +type RequestCodeParams = { + email: string + platformId: string | null +} + +type VerifyCodeParams = { + email: string + code: string + platformId: string | null +} + +type PlatformGateParams = { + email: string + platformId: string + log: FastifyBaseLogger +} + +type MayJoinPlatformParams = PlatformGateParams & { + identity: UserIdentity | null +} diff --git a/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts b/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts index 94a3028e259..cc612d57434 100644 --- a/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts +++ b/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts @@ -1,4 +1,5 @@ import { ActivepiecesError, apId, ErrorCode, isNil, spreadIfDefined } from '@activepieces/core-utils' +import { cryptoUtils } from '@activepieces/server-utils' import { UserIdentity } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { nanoid } from 'nanoid' @@ -96,6 +97,11 @@ export const userIdentityService = (log: FastifyBaseLogger) => ({ tokenVersion: nanoid(), }) }, + async updateNames({ id, firstName, lastName }: UpdateNamesParams): Promise { + await userIdentityRepository().update(id, { firstName, lastName }) + return this.getOneOrFail({ id }) + }, + async verify(id: string): Promise { const user = await userIdentityRepository().findOneByOrFail({ id }) if (user.verified) { @@ -111,6 +117,27 @@ export const userIdentityService = (log: FastifyBaseLogger) => ({ verified: true, }) }, + // A password sitting on an unverified identity was chosen by whoever typed it, + // which is not necessarily the person who reads the inbox. Verifying by emailed + // code proves the inbox, so that password must not outlive the check: keeping it + // would hand the account to anyone who registered the address first. + async verifyAndDiscardPassword(id: string): Promise { + const user = await userIdentityRepository().findOneByOrFail({ id }) + if (user.verified) { + throw new ActivepiecesError({ + code: ErrorCode.AUTHORIZATION, + params: { + message: 'User is already verified', + }, + }) + } + return userIdentityRepository().save({ + ...user, + verified: true, + password: await passwordHasher.hash(await cryptoUtils.generateRandomPassword()), + tokenVersion: nanoid(), + }) + }, async update(id: string, params: UpdateParams): Promise { await userIdentityRepository().update(id, { ...params, @@ -132,6 +159,12 @@ type GetOneOrFailParams = { id: string } +type UpdateNamesParams = { + id: string + firstName: string + lastName: string +} + type UpdatePasswordParams = { id: string newPassword: string diff --git a/packages/server/api/src/app/core/security/rate-limit.ts b/packages/server/api/src/app/core/security/rate-limit.ts index 34dc4cee6e2..af6cf1ac616 100644 --- a/packages/server/api/src/app/core/security/rate-limit.ts +++ b/packages/server/api/src/app/core/security/rate-limit.ts @@ -29,3 +29,11 @@ export const authnRateLimit: RateLimitOptions = { ), timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), } + +export const emailCodeRateLimit: RateLimitOptions = { + max: Number.parseInt( + system.getOrThrow(AppSystemProp.API_RATE_LIMIT_EMAIL_CODE_MAX), + 10, + ), + timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), +} diff --git a/packages/server/api/src/app/helper/system-validator.ts b/packages/server/api/src/app/helper/system-validator.ts index 61eb8a9cc74..27f2520eeb4 100644 --- a/packages/server/api/src/app/helper/system-validator.ts +++ b/packages/server/api/src/app/helper/system-validator.ts @@ -93,6 +93,7 @@ const systemPropValidators: { [AppSystemProp.API_RATE_LIMIT_AUTHN_ENABLED]: booleanValidator, [AppSystemProp.API_RATE_LIMIT_AUTHN_MAX]: numberValidator, [AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW]: stringValidator, + [AppSystemProp.API_RATE_LIMIT_EMAIL_CODE_MAX]: numberValidator, [AppSystemProp.CLIENT_REAL_IP_HEADER]: stringValidator, [AppSystemProp.CLOUD_AUTH_ENABLED]: booleanValidator, [AppSystemProp.CONFIG_PATH]: stringValidator, diff --git a/packages/server/api/src/app/helper/system/system-props.ts b/packages/server/api/src/app/helper/system/system-props.ts index b45042778a5..bf024cc5a67 100644 --- a/packages/server/api/src/app/helper/system/system-props.ts +++ b/packages/server/api/src/app/helper/system/system-props.ts @@ -12,6 +12,7 @@ export enum AppSystemProp { API_RATE_LIMIT_AUTHN_ENABLED = 'API_RATE_LIMIT_AUTHN_ENABLED', API_RATE_LIMIT_AUTHN_MAX = 'API_RATE_LIMIT_AUTHN_MAX', API_RATE_LIMIT_AUTHN_WINDOW = 'API_RATE_LIMIT_AUTHN_WINDOW', + API_RATE_LIMIT_EMAIL_CODE_MAX = 'API_RATE_LIMIT_EMAIL_CODE_MAX', APP_WEBHOOK_SECRETS = 'APP_WEBHOOK_SECRETS', APPSUMO_TOKEN = 'APPSUMO_TOKEN', AUTUMN_CONSOLE_URL = 'AUTUMN_CONSOLE_URL', diff --git a/packages/server/api/src/app/helper/system/system.ts b/packages/server/api/src/app/helper/system/system.ts index 9ef05cbd6b9..f042586df1d 100644 --- a/packages/server/api/src/app/helper/system/system.ts +++ b/packages/server/api/src/app/helper/system/system.ts @@ -14,6 +14,7 @@ const systemPropDefaultValues: Partial> = { [AppSystemProp.API_RATE_LIMIT_AUTHN_ENABLED]: 'true', [AppSystemProp.API_RATE_LIMIT_AUTHN_MAX]: '50', [AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW]: '1 minute', + [AppSystemProp.API_RATE_LIMIT_EMAIL_CODE_MAX]: '5', [AppSystemProp.WORKERS]: '1', [AppSystemProp.CLIENT_REAL_IP_HEADER]: 'x-real-ip', [AppSystemProp.CLOUD_AUTH_ENABLED]: 'true', diff --git a/packages/server/api/src/app/helper/telemetry.utils.ts b/packages/server/api/src/app/helper/telemetry.utils.ts index f0cae7f4cb3..12aacd85233 100644 --- a/packages/server/api/src/app/helper/telemetry.utils.ts +++ b/packages/server/api/src/app/helper/telemetry.utils.ts @@ -61,6 +61,9 @@ export const telemetry = (log: FastifyBaseLogger) => ({ const project = await projectService(log).getOne(projectId) return this.trackUser(project!.ownerId, event, { platform: project!.platformId }) }, + async trackIdentity(identityId: string, event: TelemetryEvent): Promise { + return this.trackUser(identityId, event) + }, isEnabled: () => telemetryEnabled, async trackUser(userId: UserId, event: TelemetryEvent, groups?: Record): Promise { if (!telemetryEnabled) { diff --git a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts new file mode 100644 index 00000000000..4290cd215b4 --- /dev/null +++ b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts @@ -0,0 +1,233 @@ +import { apId } from '@activepieces/core-utils' +import { OtpState, OtpType, PlatformRole, UserIdentityProvider, UserStatus } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { passwordHasher } from '../../../../src/app/authentication/lib/password-hasher' +import { otpService } from '../../../../src/app/authentication/otp/otp-service' +import { userIdentityService } from '../../../../src/app/authentication/user-identity/user-identity-service' +import { databaseConnection } from '../../../../src/app/database/database-connection' +import { platformService } from '../../../../src/app/platform/platform.service' +import { createMockPlatform } from '../../../helpers/mocks' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance | null = null + +const EMAIL = 'ahmad.tash@example.com' + +let callers = 0 + +async function requestCode(email: string): Promise { + callers += 1 + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + headers: { 'x-real-ip': `10.0.${Math.floor(callers / 256)}.${callers % 256}` }, + body: { email }, + }) + return response?.statusCode +} + +async function verifyCode({ email, code }: { email: string, code: string }) { + return app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/verify', + body: { email, code }, + }) +} + +function wrongCodeFor(code: string): string { + const shifted = (Number.parseInt(code, 10) + 1) % 1000000 + return shifted.toString().padStart(6, '0') +} + +async function storedIdentity(email: string) { + return databaseConnection().getRepository('user_identity').findOneBy({ email }) +} + +async function storedOtp(email: string) { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email }) + if (identity === null) { + return null + } + return databaseConnection().getRepository('otp').findOneBy({ + identityId: identity.id, + type: OtpType.EMAIL_LOGIN, + }) +} + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +beforeEach(async () => { + await databaseConnection().getRepository('flag').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('otp').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('project').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('platform').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user_identity').createQueryBuilder().delete().execute() +}) + +describe('Passwordless Authentication API', () => { + describe('Request code endpoint', () => { + it('creates an unverified identity and issues a 6 digit code', async () => { + const statusCode = await requestCode(EMAIL) + + expect(statusCode).toBe(StatusCodes.NO_CONTENT) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.verified).toBe(false) + expect(identity?.firstName).toBe('Ahmad') + + const otp = await storedOtp(EMAIL) + expect(otp?.value).toMatch(/^[0-9]{6}$/) + expect(otp?.state).toBe(OtpState.PENDING) + expect(otp?.attempts).toBe(0) + }) + + it('seeds the name from the email local part until the name step runs', async () => { + await requestCode(EMAIL) + + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.firstName).toBe('Ahmad') + expect(identity?.lastName).toBe('') + }) + + it('does not set the USER_CREATED flag before a code is verified', async () => { + await requestCode(EMAIL) + + const flag = await databaseConnection().getRepository('flag').findOneBy({ id: 'USER_CREATED' }) + expect(flag).toBeNull() + }) + + it('answers alike for an unknown address, revealing nothing', async () => { + const first = await requestCode(EMAIL) + const second = await requestCode('someone-else@example.com') + + expect(first).toBe(StatusCodes.NO_CONTENT) + expect(second).toBe(StatusCodes.NO_CONTENT) + }) + + it('re-sends the same code instead of minting a new one', async () => { + await requestCode(EMAIL) + const issued = await storedOtp(EMAIL) + + await requestCode(EMAIL) + const afterResend = await storedOtp(EMAIL) + + expect(afterResend?.value).toBe(issued?.value) + }) + }) + + describe('Verify code endpoint', () => { + it('signs in, verifies the identity and consumes the code', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body = response?.json() + expect(body?.email).toBe(EMAIL) + expect(body?.verified).toBe(true) + expect(body?.token).toBeDefined() + expect(await storedOtp(EMAIL)).toBeNull() + }) + + it('discards a password planted on the address before its owner proved the inbox', async () => { + const plantedPassword = 'PlantedPassword123!' + await userIdentityService(app!.log).create({ + email: EMAIL, + password: plantedPassword, + firstName: 'Ahmad', + lastName: '', + trackEvents: true, + newsLetter: false, + provider: UserIdentityProvider.EMAIL, + verified: false, + }) + const planted = await storedIdentity(EMAIL) + expect(await passwordHasher.compare(plantedPassword, planted!.password)).toBe(true) + + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const afterVerification = await storedIdentity(EMAIL) + expect(afterVerification!.verified).toBe(true) + expect(await passwordHasher.compare(plantedPassword, afterVerification!.password)).toBe(false) + }) + + it('hands a brand-new member a pre-platform session so the name step can run', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body = response?.json() + expect(body?.platformId).toBeNull() + expect(body?.projectId).toBeNull() + expect(body?.token).toBeDefined() + expect(await databaseConnection().getRepository('platform').count()).toBe(0) + }) + + it('consumes one code exactly once, even when two confirmations race it', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + const confirm = () => otpService(app!.log).confirm({ + identityId: identity!.id, + type: OtpType.EMAIL_LOGIN, + value: otp!.value, + }) + + const verdicts = await Promise.all([confirm(), confirm()]) + + expect(verdicts.filter((verdict) => verdict)).toHaveLength(1) + }) + + it('sets the USER_CREATED flag only once a code is verified', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + await verifyCode({ email: EMAIL, code: otp!.value }) + + const flag = await databaseConnection().getRepository('flag').findOneBy({ id: 'USER_CREATED' }) + expect(flag?.value).toBe(true) + }) + + it('rejects a wrong code and counts the attempt', async () => { + await requestCode(EMAIL) + const issued = await storedOtp(EMAIL) + + const response = await verifyCode({ email: EMAIL, code: wrongCodeFor(issued!.value) }) + + expect(response?.statusCode).toBe(StatusCodes.GONE) + expect((await storedOtp(EMAIL))?.attempts).toBe(1) + }) + + it('discards the credential after five wrong attempts', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + for (let attempt = 0; attempt < 5; attempt++) { + await verifyCode({ email: EMAIL, code: wrongCodeFor(otp!.value) }) + } + + expect(await storedOtp(EMAIL)).toBeNull() + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + expect(response?.statusCode).toBe(StatusCodes.GONE) + }) + + it('rejects an address that never requested a code', async () => { + const response = await verifyCode({ email: 'nobody@example.com', code: '123456' }) + + expect(response?.statusCode).toBe(StatusCodes.GONE) + }) + }) +}) From 6186df08b12a565a5392c70267b96c01169adbbe Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:18:55 +0300 Subject: [PATCH 06/12] feat(auth): finish sign-up by asking for a name (#14708) --- .../authentication.controller.ts | 33 ++++- .../passwordless-auth.service.ts | 27 ++++ .../authentication/passwordless-authn.test.ts | 116 ++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/packages/server/api/src/app/authentication/authentication.controller.ts b/packages/server/api/src/app/authentication/authentication.controller.ts index b87e48553bf..41b5a89363a 100644 --- a/packages/server/api/src/app/authentication/authentication.controller.ts +++ b/packages/server/api/src/app/authentication/authentication.controller.ts @@ -1,5 +1,5 @@ import { isNil } from '@activepieces/core-utils' -import { ApplicationEventName, PrincipalType, RequestEmailCodeRequest, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider, VerifyEmailCodeRequest } from '@activepieces/shared' +import { ApplicationEventName, CompleteSignUpRequest, PrincipalType, RequestEmailCodeRequest, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider, VerifyEmailCodeRequest } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' import { securityAccess } from '../core/security/authorization/fastify-security' @@ -114,6 +114,27 @@ export const authenticationController: FastifyPluginAsyncZod = async ( return response }) + app.post('/complete-sign-up', CompleteSignUpRequestOptions, async (request) => { + const { response, signedUp } = await passwordlessAuthService(request.log).completeSignUp({ + identityId: request.principal.id, + fullName: request.body.fullName, + }) + + if (signedUp && !isNil(response.platformId)) { + applicationEvents(request.log).sendUserEvent({ + platformId: response.platformId, + userId: response.id, + projectId: response.projectId ?? undefined, + ip: networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), + }, { + action: ApplicationEventName.USER_SIGNED_UP, + data: {}, + }) + } + + return response + }) + app.post('/switch-platform', SwitchPlatformRequestOptions, async (request) => { const user = await userService(request.log).getOneOrFail({ id: request.principal.id }) return authenticationService(request.log).switchPlatform({ @@ -146,6 +167,16 @@ const SignUpRequestOptions = { }, } +const CompleteSignUpRequestOptions = { + config: { + security: securityAccess.unscoped([PrincipalType.ONBOARDING]), + rateLimit: authnRateLimit, + }, + schema: { + body: CompleteSignUpRequest, + }, +} + const RequestEmailCodeRequestOptions = { config: { security: securityAccess.public(), diff --git a/packages/server/api/src/app/authentication/passwordless-auth.service.ts b/packages/server/api/src/app/authentication/passwordless-auth.service.ts index c4b26fa8776..4cb5594bc9c 100644 --- a/packages/server/api/src/app/authentication/passwordless-auth.service.ts +++ b/packages/server/api/src/app/authentication/passwordless-auth.service.ts @@ -7,6 +7,7 @@ import { rejectedPromiseHandler } from '../helper/promise-handler' import { system } from '../helper/system/system' import { AppSystemProp } from '../helper/system/system-props' import { telemetry } from '../helper/telemetry.utils' +import { platformService } from '../platform/platform.service' import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { authenticationUtils } from './authentication-utils' @@ -113,6 +114,22 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ return authenticationUtils(log).getOnboardingResponse({ identityId: verifiedIdentity.id }) }, + async completeSignUp({ identityId, fullName }: CompleteSignUpParams): Promise { + const identity = await userIdentityService(log).getOneOrFail({ id: identityId }) + const { firstName, lastName } = signupNames.splitFullName({ fullName, email: identity.email }) + const writeNames = async (): Promise => { + await userIdentityService(log).updateNames({ id: identityId, firstName, lastName }) + } + const { response, provisioned } = await platformService(log).createPlatformWithProject({ + identityId, + name: signupNames.platformNameFromPerson({ firstName, email: identity.email }), + invalidatePreviousTokens: false, + isFirstPlatform: true, + callerTokenVersion: undefined, + beforeProvision: writeNames, + }) + return { response, signedUp: provisioned } + }, }) async function assertPlatformAuthIsOpenTo({ email, platformId, log }: PlatformGateParams): Promise { @@ -140,6 +157,16 @@ type RequestCodeParams = { platformId: string | null } +type CompleteSignUpResult = { + response: AuthenticationResponse + signedUp: boolean +} + +type CompleteSignUpParams = { + identityId: string + fullName: string +} + type VerifyCodeParams = { email: string code: string diff --git a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts index 4290cd215b4..9dc04b92cc0 100644 --- a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts +++ b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts @@ -6,6 +6,7 @@ import { passwordHasher } from '../../../../src/app/authentication/lib/password- import { otpService } from '../../../../src/app/authentication/otp/otp-service' import { userIdentityService } from '../../../../src/app/authentication/user-identity/user-identity-service' import { databaseConnection } from '../../../../src/app/database/database-connection' +import { passwordlessAuthService } from '../../../../src/app/authentication/passwordless-auth.service' import { platformService } from '../../../../src/app/platform/platform.service' import { createMockPlatform } from '../../../helpers/mocks' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' @@ -176,6 +177,31 @@ describe('Passwordless Authentication API', () => { expect(await databaseConnection().getRepository('platform').count()).toBe(0) }) + it('creates the platform from the name once the name step completes', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName: 'Ahmad Bin Tash' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body = response?.json() + expect(body?.projectId).not.toBeNull() + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.firstName).toBe('Ahmad') + expect(identity?.lastName).toBe('Bin Tash') + const platform = await databaseConnection().getRepository('platform').findOneBy({ id: body?.platformId }) + expect(platform?.name).toBe("Ahmad's Platform") + const project = await databaseConnection().getRepository('project').findOneBy({ platformId: body?.platformId }) + expect(project?.displayName).toBe("Ahmad's Project") + }) + it('consumes one code exactly once, even when two confirmations race it', async () => { await requestCode(EMAIL) const otp = await storedOtp(EMAIL) @@ -191,6 +217,96 @@ describe('Passwordless Authentication API', () => { expect(verdicts.filter((verdict) => verdict)).toHaveLength(1) }) + it('creates one platform for one identity, even when the name step is submitted twice', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + const completeSignUp = () => app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName: 'Ahmad Bin Tash' }, + }) + + const first = await completeSignUp() + const second = await completeSignUp() + + expect(first?.statusCode).toBe(StatusCodes.OK) + expect(second?.statusCode).toBe(StatusCodes.OK) + expect(second?.json()?.platformId).toBe(first?.json()?.platformId) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + expect(await databaseConnection().getRepository('project').count()).toBe(1) + expect(await databaseConnection().getRepository('user').count()).toBe(1) + }) + + it('creates one platform even when the other onboarding route races the name step', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + + const viaNameStep = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName: 'Ahmad Bin Tash' }, + }) + const viaPlatformRoute = await app?.inject({ + method: 'POST', + url: '/api/v1/platforms', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { name: 'Ahmad' }, + }) + + expect(viaNameStep?.statusCode).toBe(StatusCodes.OK) + expect(viaPlatformRoute?.statusCode).toBe(StatusCodes.OK) + expect(viaPlatformRoute?.json()?.platformId).toBe(viaNameStep?.json()?.platformId) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + expect(await databaseConnection().getRepository('user').count()).toBe(1) + }) + + it('does not rename an account whose chosen name matches its address', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + const complete = (fullName: string) => app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName }, + }) + await complete('Ahmad') + + await complete('Someone Else') + + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.firstName).toBe('Ahmad') + expect(identity?.lastName).toBe('') + }) + + it('does not rename the account when completion is replayed', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + const complete = (fullName: string) => app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName }, + }) + await complete('Ahmad Tash') + + const replay = await complete('Someone Else') + + expect(replay?.statusCode).toBe(StatusCodes.OK) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.firstName).toBe('Ahmad') + expect(identity?.lastName).toBe('Tash') + }) + it('sets the USER_CREATED flag only once a code is verified', async () => { await requestCode(EMAIL) const otp = await storedOtp(EMAIL) From d758825cbf6e5a7c8174ef6473ceef95d637b42c Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:18:56 +0300 Subject: [PATCH 07/12] feat(web): building blocks for the unified auth screen (#14709) --- bun.lock | 3 + packages/web/package.json | 7 +- .../web/public/locales/en/translation.json | 3 +- packages/web/src/api/authentication-api.ts | 18 ++ .../web/src/components/custom/full-logo.tsx | 5 +- packages/web/src/components/ui/input-otp.tsx | 76 ++++++ .../components/auth-landing/auth-backdrop.tsx | 230 ++++++++++++++++++ .../components/saml-login-form.tsx | 18 +- .../components/third-party-logins.tsx | 60 ++++- .../authentication/hooks/auth-hooks.ts | 50 ++++ packages/web/src/lib/api.ts | 2 + 11 files changed, 451 insertions(+), 21 deletions(-) create mode 100644 packages/web/src/components/ui/input-otp.tsx create mode 100644 packages/web/src/features/authentication/components/auth-landing/auth-backdrop.tsx diff --git a/bun.lock b/bun.lock index 038b6187a90..553543f081e 100644 --- a/bun.lock +++ b/bun.lock @@ -10964,6 +10964,7 @@ "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "3.0.5", "i18next-icu": "2.3.0", + "input-otp": "1.4.2", "jszip": "3.10.1", "jwt-decode": "4.0.0", "lucide-react": "0.576.0", @@ -15858,6 +15859,8 @@ "inline-style-prefixer": ["inline-style-prefixer@7.0.1", "", { "dependencies": { "css-in-js-utils": "^3.1.0" } }, "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw=="], + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + "inquirer": ["inquirer@8.2.7", "", { "dependencies": { "@inquirer/external-editor": "^1.0.0", "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^6.0.1" } }, "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA=="], "intercom-client": ["intercom-client@6.2.0", "", { "dependencies": { "form-data": "^4.0.0", "formdata-node": "^6.0.3", "js-base64": "3.7.7", "node-fetch": "^2.7.0", "qs": "^6.13.1", "readable-stream": "^4.5.2", "url-join": "4.0.1" } }, "sha512-ta9UB6twCk6b4OLiC1HUtB0NgDCpHLtiXfWSV8QjAUmXzjq1aBd4axKW6CgPiEnBhrkTdeJ4u0Mvx/MRzXlVWw=="], diff --git a/packages/web/package.json b/packages/web/package.json index 4cfca0b474b..f4de6f72377 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -3,6 +3,8 @@ "version": "0.0.1", "private": true, "dependencies": { + "@activepieces/core-formula": "workspace:*", + "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "@activepieces/shared": "workspace:*", "@codemirror/commands": "6.10.3", @@ -68,6 +70,7 @@ "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "3.0.5", "i18next-icu": "2.3.0", + "input-otp": "1.4.2", "jszip": "3.10.1", "jwt-decode": "4.0.0", "lucide-react": "0.576.0", @@ -111,9 +114,7 @@ "use-stick-to-bottom": "1.1.3", "vaul": "1.1.2", "zod": "4.3.6", - "zustand": "4.5.4", - "@activepieces/core-utils": "workspace:*", - "@activepieces/core-formula": "workspace:*" + "zustand": "4.5.4" }, "devDependencies": { "@tailwindcss/postcss": "4.1.17", diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index e1fef388ac5..c576bc787e1 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2295,5 +2295,6 @@ "1 min ago": "1 min ago", "{count} mins ago": "{count} mins ago", "1 hour ago": "1 hour ago", - "{count} hours ago": "{count} hours ago" + "{count} hours ago": "{count} hours ago", + "Continue with Google": "Continue with Google" } diff --git a/packages/web/src/api/authentication-api.ts b/packages/web/src/api/authentication-api.ts index 6c329173c96..6a154591514 100644 --- a/packages/web/src/api/authentication-api.ts +++ b/packages/web/src/api/authentication-api.ts @@ -2,6 +2,8 @@ import { ProjectRole } from '@activepieces/core-utils'; import { CreateOtpRequestBody, GetCurrentProjectMemberRoleQuery, + CompleteSignUpRequest, + RequestEmailCodeRequest, ResetPasswordRequestBody, VerifyEmailRequestBody, AuthenticationResponse, @@ -12,6 +14,7 @@ import { SwitchPlatformRequest, ThirdPartyAuthnProviderEnum, UserIdentity, + VerifyEmailCodeRequest, } from '@activepieces/shared'; import { api } from '@/lib/api'; @@ -43,6 +46,21 @@ export const authenticationApi = { request, ); }, + requestEmailCode(request: RequestEmailCodeRequest) { + return api.post('/v1/authentication/otp/request', request); + }, + completeSignUp(request: CompleteSignUpRequest) { + return api.post( + '/v1/authentication/complete-sign-up', + request, + ); + }, + verifyEmailCode(request: VerifyEmailCodeRequest) { + return api.post( + '/v1/authentication/otp/verify', + request, + ); + }, sendOtpEmail(request: CreateOtpRequestBody) { return api.post('/v1/otp', request); }, diff --git a/packages/web/src/components/custom/full-logo.tsx b/packages/web/src/components/custom/full-logo.tsx index 0e4a5d98a5c..7567a811a30 100644 --- a/packages/web/src/components/custom/full-logo.tsx +++ b/packages/web/src/components/custom/full-logo.tsx @@ -1,12 +1,13 @@ import { t } from 'i18next'; import { flagsHooks } from '@/hooks/flags-hooks'; +import { cn } from '@/lib/utils'; -const FullLogo = () => { +const FullLogo = ({ className }: { className?: string }) => { const branding = flagsHooks.useWebsiteBranding(); return ( -
+
, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)); +InputOTP.displayName = 'InputOTP'; + +const InputOTPGroup = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> +>(({ className, ...props }, ref) => ( +
+)); +InputOTPGroup.displayName = 'InputOTPGroup'; + +const InputOTPSlot = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext); + const slot = inputOTPContext.slots[index]; + const char = slot?.char; + const hasFakeCaret = slot?.hasFakeCaret; + const isActive = slot?.isActive; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +}); +InputOTPSlot.displayName = 'InputOTPSlot'; + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> +>(({ ...props }, ref) => ( +
+ +
+)); +InputOTPSeparator.displayName = 'InputOTPSeparator'; + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-backdrop.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-backdrop.tsx new file mode 100644 index 00000000000..1d2522646c7 --- /dev/null +++ b/packages/web/src/features/authentication/components/auth-landing/auth-backdrop.tsx @@ -0,0 +1,230 @@ +import { + ArrowUp, + BarChart3, + Check, + ChevronsUpDown, + House, + MessageCircle, + Mic, + Paperclip, + Plus, + Search, + Sparkles, + Table2, + Workflow, +} from 'lucide-react'; + +import { flagsHooks } from '@/hooks/flags-hooks'; + +export function AuthBackdrop() { + const branding = flagsHooks.useWebsiteBranding(); + const logoUrl = branding.logos.logoIconUrl; + + return ( +
+ +
+
+
+ + + Daily Stripe summary + +
+
+
+ {CONVERSATION.map((turn, index) => + turn.role === 'user' ? ( + + ) : ( + + ), + )} +
+
+
+
+ +
+
+
+
+
+ ); +} + +function SidebarFacsimile({ logoUrl }: { logoUrl: string }) { + return ( +
+
+ + + Acme Inc + + +
+ +
+ + New chat +
+ +
+ {NAV_ITEMS.map(({ icon: Icon, label, active }) => ( +
+ + {label} +
+ ))} +
+ +
+ + Recent + + {RECENT_CHATS.map((title, index) => ( +
+ {title} +
+ ))} +
+ +
+
+
+
+
+ ); +} + +function UserTurn({ text }: { text: string }) { + return ( +
+
+ {text} +
+
+ ); +} + +function AssistantTurn({ turn }: { turn: AssistantTurnData }) { + return ( +
+ {turn.activity && ( + + + {turn.activity} + + )} +

+ {turn.text} +

+ {turn.steps && ( +
+ {turn.steps.map((step) => ( +
+ + {step} +
+ ))} +
+ )} +
+ ); +} + +function ComposerFacsimile() { + return ( +
+

+ Tell me what you need... (@ to mention, : for emoji) +

+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ ); +} + +const NAV_ITEMS = [ + { icon: House, label: 'Home', active: false }, + { icon: MessageCircle, label: 'Chats', active: true }, + { icon: Workflow, label: 'Automations', active: false }, + { icon: Table2, label: 'Tables', active: false }, + { icon: BarChart3, label: 'Insights', active: false }, + { icon: Search, label: 'Search', active: false }, +]; + +const RECENT_CHATS = [ + 'Daily Stripe summary', + 'Chase overdue invoices', + 'Onboard new signups', + 'Weekly report to leadership', + 'Sync HubSpot to Sheets', + 'Tidy up my inbox', +]; + +const CONVERSATION: Turn[] = [ + { + role: 'user', + text: "Every morning, pull yesterday's Stripe payments into a Google Sheet and post a summary in Slack.", + }, + { + role: 'assistant', + activity: 'Checked Stripe, Google Sheets and Slack', + text: 'Done. It runs at 8:00 every morning, writes one row per payment, and posts the daily total to #finance.', + steps: [ + 'Every day at 08:00', + 'Stripe: list yesterday’s payments', + 'Google Sheets: append rows', + 'Slack: send summary to #finance', + ], + }, + { + role: 'user', + text: 'Nice. Also ping me if a payment fails.', + }, + { + role: 'assistant', + text: 'Added a branch: failed payments now send you a direct message the moment Stripe reports them.', + }, +]; + +type AssistantTurnData = { + role: 'assistant'; + text: string; + activity?: string; + steps?: string[]; +}; + +type Turn = AssistantTurnData | { role: 'user'; text: string }; diff --git a/packages/web/src/features/authentication/components/saml-login-form.tsx b/packages/web/src/features/authentication/components/saml-login-form.tsx index 99bf9ebaf23..08fdc53ead9 100644 --- a/packages/web/src/features/authentication/components/saml-login-form.tsx +++ b/packages/web/src/features/authentication/components/saml-login-form.tsx @@ -25,9 +25,15 @@ type FormValues = z.infer; type SamlLoginFormProps = { onBack: () => void; + // The auth card supplies its own back affordance, so it opts out of this + // one rather than showing two. + showBackButton?: boolean; }; -export const SamlLoginForm = ({ onBack }: SamlLoginFormProps) => { +export const SamlLoginForm = ({ + onBack, + showBackButton = true, +}: SamlLoginFormProps) => { const form = useForm({ resolver: zodResolver(FormValues), defaultValues: { email: '' }, @@ -92,10 +98,12 @@ export const SamlLoginForm = ({ onBack }: SamlLoginFormProps) => { > {t('Continue')} - + {showBackButton && ( + + )} ); diff --git a/packages/web/src/features/authentication/components/third-party-logins.tsx b/packages/web/src/features/authentication/components/third-party-logins.tsx index 1fd7ff8e51d..fefc0986e13 100644 --- a/packages/web/src/features/authentication/components/third-party-logins.tsx +++ b/packages/web/src/features/authentication/components/third-party-logins.tsx @@ -17,17 +17,41 @@ import { internalErrorToast } from '@/components/ui/sonner'; import { oauth2Utils } from '@/features/connections/utils/oauth2-utils'; import { flagsHooks } from '@/hooks/flags-hooks'; +// Mirrors the render gates below so callers can hide surrounding chrome — an +// "or" divider — or place each provider themselves. SAML is offered on cloud +// for enterprise SSO, and self-hosted only once a SAML config exists. +function useThirdPartyAvailability(): ThirdPartyAvailability { + const { data: thirdPartyAuthProviders } = + flagsHooks.useFlag( + ApFlagId.THIRD_PARTY_AUTH_PROVIDERS_TO_SHOW_MAP, + ); + const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); + const isCloud = edition === ApEdition.CLOUD; + return { + google: Boolean(thirdPartyAuthProviders?.google), + saml: isCloud || Boolean(thirdPartyAuthProviders?.saml), + samlIsCloud: isCloud, + }; +} + +function useShowThirdPartyProviders(): boolean { + const { google, saml } = useThirdPartyAvailability(); + return google || saml; +} + const ThirdPartyIcon = ({ icon }: { icon: string }) => { - return icon; + return icon; }; const ThirdPartyLogin = React.memo( ({ isSignUp, onSamlClick, + hideSaml = false, }: { isSignUp: boolean; onSamlClick: () => void; + hideSaml?: boolean; }) => { const { data: thirdPartyAuthProviders } = flagsHooks.useFlag( @@ -40,6 +64,9 @@ const ThirdPartyLogin = React.memo( const isCloud = edition === ApEdition.CLOUD; const thirdPartyLogin = oauth2Utils.useThirdPartyLogin(); const { capture } = useTelemetry(); + const availability = useThirdPartyAvailability(); + const showProviders = + availability.google || (!hideSaml && availability.saml); const handleProviderClick = async ( event: React.MouseEvent, @@ -67,26 +94,28 @@ const ThirdPartyLogin = React.memo( thirdPartyLogin(loginUrl, providerName); }; + if (!showProviders) { + return null; + } + return (
{thirdPartyAuthProviders?.google && ( )} - {isCloud && ( + {!hideSaml && isCloud && ( )} - {!isCloud && thirdPartyAuthProviders?.saml && ( + {!hideSaml && !isCloud && thirdPartyAuthProviders?.saml && ( + {thirdParty.saml && ( + <> + + • + + + + )} +
+ + + ); +} + +function LegalNote() { + const { data: termsUrl } = flagsHooks.useFlag( + ApFlagId.TERMS_OF_SERVICE_URL, + ); + const { data: privacyUrl } = flagsHooks.useFlag( + ApFlagId.PRIVACY_POLICY_URL, + ); + + if (isNil(termsUrl) && isNil(privacyUrl)) { + return null; + } + + return ( +

+ {t('By continuing, you agree to our')}{' '} + {!isNil(termsUrl) && ( + + {t('Terms of Service')} + + )} + {!isNil(termsUrl) && !isNil(privacyUrl) && ` ${t('and')} `} + {!isNil(privacyUrl) && ( + + {t('Privacy Policy')} + + )} +

+ ); +} + +// A gentle nudge, never a blocker: personal addresses still work. It sits +// inside the field's own container, under a hairline. A lightbulb, not an +// alert — anything that reads as an error here costs signups. +function WorkEmailHint() { + return ( +
+ +

{t('Use your work email for better personalization.')}

+
+ ); +} + +function EmailStep({ invitedEmail, onCodeSent }: EmailStepProps) { + const form = useForm({ + resolver: zodResolver(EmailZodSchema), + defaultValues: { email: invitedEmail }, + // Never call an address invalid before the user has actually tried to + // continue — not while typing, not on blur. After a failed submit it + // corrects live as they fix it. + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + + // Only nudge once the address is actually complete, so the hint doesn't + // flicker while someone is still typing their domain. + const email = form.watch('email'); + const emailError = !!form.formState.errors.email; + const showWorkEmailHint = + formatUtils.emailRegex.test(email.trim()) && isPersonalEmail(email); + + const { mutate, isPending } = authMutations.useRequestEmailCode({ + onSuccess: () => onCodeSent(form.getValues().email.trim()), + onError: (error) => + form.setError('root.serverError', { + message: requestErrorMessage(error), + }), + }); + + const onSubmit: SubmitHandler = (data) => { + form.clearErrors('root.serverError'); + mutate({ email: data.email.trim() }); + }; + + return ( +
+ + ( + + {/* One field, one affordance: the submit arrow lives inside the + input. When the address is personal the container grows a + note beneath the field — the field and the nudge read as one + object rather than a warning bolted underneath. */} +
+
+ + + +
+ {emailError ? ( +
+ +

{t('That doesn’t look like an email address yet.')}

+
+ ) : ( + showWorkEmailHint && + )} +
+
+ )} + /> + {form?.formState?.errors?.root?.serverError && ( + + {form.formState.errors.root.serverError.message} + + )} + + + ); +} + +function ResetStep() { + const [sentTo, setSentTo] = useState(null); + const form = useForm({ + resolver: zodResolver(EmailZodSchema), + defaultValues: { email: '' }, + mode: 'onChange', + }); + + const { mutate, isPending } = useMutation< + void, + HttpError, + CreateOtpRequestBody + >({ + mutationFn: authenticationApi.sendOtpEmail, + onSuccess: () => setSentTo(form.getValues().email.trim().toLowerCase()), + }); + + if (sentTo) { + return ( + <> + + + + ); + } + + return ( + <> + +
+ + mutate({ + email: data.email.trim().toLowerCase(), + type: OtpType.PASSWORD_RESET, + }), + )} + > + ( + +
+ + +
+ +
+ )} + /> + + + + + ); +} + +function CodeStep({ email, onBack, onNeedsName }: CodeStepProps) { + const [code, setCode] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS); + const redirectAfterLogin = useRedirectAfterLogin(); + const { capture } = useTelemetry(); + + useEffect(() => { + if (cooldown <= 0) { + return; + } + const timer = setTimeout(() => setCooldown((value) => value - 1), 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + const { mutate: verify, isPending: isVerifying } = + authMutations.useVerifyEmailCode({ + onSuccess: (data) => { + authenticationSession.saveResponse(data, false); + // A brand-new member arrives on the pre-platform onboarding token, so + // there is no project yet: ask their name before building the platform. + if (isNil(data.projectId)) { + onNeedsName(); + return; + } + redirectAfterLogin(); + }, + onError: (error) => { + setCode(''); + setErrorMessage(codeErrorMessage(error)); + capture({ + name: TelemetryEventName.EMAIL_CODE_REJECTED, + payload: { errorCode: serverErrorCode(error) ?? 'UNKNOWN' }, + }); + }, + }); + + const { mutate: resend, isPending: isResending } = + authMutations.useRequestEmailCode({ + onSuccess: () => { + setCooldown(RESEND_COOLDOWN_SECONDS); + capture({ + name: TelemetryEventName.EMAIL_CODE_RESEND_REQUESTED, + payload: {}, + }); + }, + onError: () => + setErrorMessage(t('Something went wrong, please try again later')), + }); + + const handleChange = (value: string) => { + setErrorMessage(null); + setCode(value); + if (value.length === CODE_LENGTH) { + verify({ email, code: value }); + } + }; + + return ( + <> + + +
+ + + {Array.from({ length: CODE_LENGTH }).map((_, index) => ( + + ))} + + + {errorMessage && ( +

{errorMessage}

+ )} + +
+ + ); +} + +function DrawerShell({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+ {children} +
+ ); +} + +function Heading({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ ); +} + +function BackLink({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +function ModeSwitch({ + mode, + onSwitch, +}: { + mode: AuthMode; + onSwitch: (mode: AuthMode) => void; +}) { + return ( +
+ {mode === 'signup' + ? t('Already have an account?') + : t("Don't have an account?")} + +
+ ); +} + +// Country variants are endless (yahoo.co.uk, hotmail.fr, …), so match the +// provider by prefix and keep the exact list for the one-off domains. +function isPersonalEmail(email: string): boolean { + const domain = email.trim().toLowerCase().split('@')[1]; + if (!domain) { + return false; + } + return ( + PERSONAL_EMAIL_DOMAINS.has(domain) || + PERSONAL_EMAIL_PREFIXES.some((prefix) => domain.startsWith(prefix)) + ); +} + +function requestErrorMessage(error: HttpError): string { + if (api.isError(error)) { + const errorCode = (error.response?.data as { code?: ErrorCode })?.code; + if (errorCode === ErrorCode.INVITATION_ONLY_SIGN_UP) { + return t('You need an invitation to sign up.'); + } + if (errorCode === ErrorCode.DOMAIN_NOT_ALLOWED) { + return t('Email domain is disallowed'); + } + if (errorCode === ErrorCode.EMAIL_AUTH_DISABLED) { + return t('Email sign-in is disabled'); + } + } + return t('Something went wrong, please try again later'); +} + +function serverErrorCode(error: HttpError): string | undefined { + return (error.response?.data as { code?: string })?.code; +} + +function codeErrorMessage(error: HttpError): string { + if (api.isError(error)) { + const errorCode = (error.response?.data as { code?: ErrorCode })?.code; + if (errorCode === ErrorCode.INVALID_OTP) { + return t('That code is invalid or expired. Try again.'); + } + if (errorCode === ErrorCode.DOMAIN_NOT_ALLOWED) { + return t('Email domain is disallowed'); + } + if (errorCode === ErrorCode.INVITATION_ONLY_SIGN_UP) { + return t('You need an invitation to sign up.'); + } + } + return t('Something went wrong, please try again later'); +} + +const PERSONAL_EMAIL_DOMAINS = new Set([ + 'gmail.com', + 'googlemail.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'aol.com', + 'msn.com', + 'protonmail.com', + 'proton.me', + 'mail.com', + 'zoho.com', + 'yandex.com', + 'qq.com', + '163.com', + '126.com', + 'naver.com', + 'web.de', + 'orange.fr', + 'free.fr', +]); + +const PERSONAL_EMAIL_PREFIXES = [ + 'yahoo.', + 'hotmail.', + 'outlook.', + 'live.', + 'gmx.', +]; + +const EmailZodSchema = z.object({ + email: z.string().trim().regex(formatUtils.emailRegex, 'Email is invalid'), +}); + +type EmailSchema = z.infer; + +type CodeStepProps = { + email: string; + onBack: () => void; + onNeedsName: () => void; +}; + +type EmailStepProps = { + invitedEmail: string; + onCodeSent: (email: string) => void; +}; + +type AuthDrawerBodyProps = { + initialMode: AuthMode; +}; + +type AuthStepProps = { + step: Step; + setStep: Dispatch>; + samlOpen: boolean; + setSamlOpen: Dispatch>; + mode: AuthMode; + setMode: Dispatch>; + emailForCode: string; + setEmailForCode: Dispatch>; + checkEmailNote: boolean; + setCheckEmailNote: Dispatch>; + invitedEmail: string; +}; + +type Step = 'method' | 'code' | 'password' | 'reset'; + +export type AuthMode = 'signin' | 'signup'; diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx new file mode 100644 index 00000000000..e3d3d5f6573 --- /dev/null +++ b/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx @@ -0,0 +1,96 @@ +import { isNil } from '@activepieces/core-utils'; +import { t } from 'i18next'; +import { useEffect, useRef } from 'react'; + +import { useTheme } from '@/components/providers/theme-provider'; +import { authenticationSession } from '@/lib/authentication-session'; +import { useRedirectAfterLogin } from '@/lib/navigation-utils'; + +import { AuthBackdrop } from './auth-backdrop'; +import { AuthDrawerBody, AuthMode } from './auth-drawer-body'; + +const NUDGE_STREAK_WINDOW_MS = 700; + +export function AuthLanding({ initialMode }: AuthLandingProps) { + const { setForceLightMode } = useTheme(); + const redirectAfterLogin = useRedirectAfterLogin(); + const signedIn = !isNil(authenticationSession.getToken()); + const panelRef = useRef(null); + const nudgeRef = useRef<{ + lastAt: number; + streak: number; + animation: Animation | null; + }>({ + lastAt: 0, + streak: 0, + animation: null, + }); + + useEffect(() => { + setForceLightMode(true); + return () => setForceLightMode(false); + }, [setForceLightMode]); + + useEffect(() => { + if (signedIn) { + redirectAfterLogin(); + } + }, [signedIn, redirectAfterLogin]); + + if (signedIn) { + return null; + } + + const nudgePanel = () => { + const panel = panelRef.current; + if (!panel) { + return; + } + panel + .querySelector( + 'input:not([type="hidden"]):not([disabled])', + ) + ?.focus(); + const state = nudgeRef.current; + const now = performance.now(); + state.streak = + now - state.lastAt < NUDGE_STREAK_WINDOW_MS ? state.streak + 1 : 0; + state.lastAt = now; + const peak = Math.min(1.015 + state.streak * 0.008, 1.045); + state.animation?.cancel(); + state.animation = panel.animate( + [ + { transform: 'scale(1)' }, + { transform: `scale(${peak})`, offset: 0.3 }, + { transform: 'scale(0.997)', offset: 0.6 }, + { transform: 'scale(1)' }, + ], + { duration: 320, easing: 'ease-in-out' }, + ); + }; + + return ( +
+ +
+
+
+ +
+
+
+ ); +} + +type AuthLandingProps = { + initialMode: AuthMode; +}; diff --git a/packages/web/src/features/authentication/components/integration-logos-overlay.tsx b/packages/web/src/features/authentication/components/integration-logos-overlay.tsx deleted file mode 100644 index 6310dd5ebca..00000000000 --- a/packages/web/src/features/authentication/components/integration-logos-overlay.tsx +++ /dev/null @@ -1,45 +0,0 @@ -const CLIENTS = [ - { - name: 'MoneyGram', - src: 'https://www.activepieces.com/logos/moneygram.svg', - }, - { name: 'Red Bull', src: 'https://www.activepieces.com/logos/redbull.svg' }, - { name: 'Rakuten', src: 'https://www.activepieces.com/logos/rakuten.svg' }, - { name: 'DocuSign', src: 'https://www.activepieces.com/logos/docusign.svg' }, - { - name: 'Contentful', - src: 'https://www.activepieces.com/logos/contentful.svg', - }, - { name: 'PostHog', src: 'https://www.activepieces.com/logos/posthog.svg' }, - { name: 'Roblox', src: 'https://www.activepieces.com/logos/roblox.svg' }, - { name: 'Alan', src: 'https://www.activepieces.com/logos/alan.svg' }, - { - name: 'Funding Societies', - src: 'https://www.activepieces.com/logos/fundingsocieties-sales.png', - }, - { name: 'Plivo', src: 'https://www.activepieces.com/logos/plivo.svg' }, - { name: 'Nedap', src: 'https://www.activepieces.com/logos/nedap.svg' }, - { - name: 'Experience.com', - src: 'https://www.activepieces.com/logos/experience.com.svg', - }, -] as const; - -export const IntegrationLogosOverlay = () => { - return ( -
- {CLIENTS.map(({ name, src }) => ( - {name} { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - ))} -
- ); -}; diff --git a/packages/web/src/features/authentication/components/sign-in-form.tsx b/packages/web/src/features/authentication/components/sign-in-form.tsx index 6ed1182bb9f..66befacae33 100644 --- a/packages/web/src/features/authentication/components/sign-in-form.tsx +++ b/packages/web/src/features/authentication/components/sign-in-form.tsx @@ -13,7 +13,7 @@ import { t } from 'i18next'; import { Eye, EyeOff } from 'lucide-react'; import { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { Link, Navigate, useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { z } from 'zod'; import { authenticationApi } from '@/api/authentication-api'; @@ -37,7 +37,7 @@ const SignInSchema = z.object({ type SignInSchema = z.infer; -const SignInForm: React.FC = () => { +const SignInForm = ({ onForgotPassword }: SignInFormProps) => { const [showCheckYourEmailNote, setShowCheckYourEmailNote] = useState(false); const [showPassword, setShowPassword] = useState(false); const form = useForm({ @@ -51,7 +51,6 @@ const SignInForm: React.FC = () => { const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); - const { data: userCreated } = flagsHooks.useFlag(ApFlagId.USER_CREATED); const redirectAfterLogin = useRedirectAfterLogin(); const navigate = useNavigate(); const { capture } = useTelemetry(); @@ -136,10 +135,6 @@ const SignInForm: React.FC = () => { mutate(data); }; - if (!userCreated) { - return ; - } - return ( <>
@@ -175,14 +170,25 @@ const SignInForm: React.FC = () => {
- {edition !== ApEdition.COMMUNITY && ( - - {t('Forgot your password?')} - - )} + {edition !== ApEdition.COMMUNITY && + // Inside the auth card the reset flow is another step, not + // another page — the caller hands us a handler for it. + (onForgotPassword ? ( + + ) : ( + + {t('Forgot your password?')} + + ))}
{ SignInForm.displayName = 'SignIn'; export { SignInForm }; + +type SignInFormProps = { + onForgotPassword?: () => void; +}; diff --git a/packages/web/src/features/authentication/index.ts b/packages/web/src/features/authentication/index.ts index 0ee2007fe5e..aabf1b310cb 100644 --- a/packages/web/src/features/authentication/index.ts +++ b/packages/web/src/features/authentication/index.ts @@ -1,6 +1,7 @@ export { managedAuthApi } from './api/managed-auth-api'; export { authMutations } from './hooks/auth-hooks'; -export { AuthFormTemplate, AuthLayout } from './components/auth-form-template'; +export { AuthLayout } from './components/auth-form-template'; +export { AuthLanding } from './components/auth-landing/auth-landing'; export { ChangePasswordForm } from './components/change-password'; export { CheckEmailNote } from './components/check-email-note'; export { From 7b60d5d2d5bce01c6a8598bd81223306a50c2085 Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:18:57 +0300 Subject: [PATCH 09/12] feat(web): ask new members their name in the card (#14690) --- .../web/public/locales/en/translation.json | 7 +- .../web/src/app/routes/create-platform.tsx | 128 +----------- .../auth-landing/auth-drawer-body.tsx | 189 +++++++++++++++++- .../components/auth-landing/auth-landing.tsx | 4 +- 4 files changed, 197 insertions(+), 131 deletions(-) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 37601d2d436..1a83dce8010 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2317,5 +2317,10 @@ "We sent a 6-digit code to {email}": "We sent a 6-digit code to {email}", "Welcome": "Welcome", "You need an invitation to sign up.": "You need an invitation to sign up.", - "name@work.com": "name@work.com" + "name@work.com": "name@work.com", + "Email verified": "Email verified", + "Full Name": "Full Name", + "Tell us your name so we know what to call you.": "Tell us your name so we know what to call you.", + "This names your workspace and how we greet you.": "This names your workspace and how we greet you.", + "What should we call you?": "What should we call you?" } diff --git a/packages/web/src/app/routes/create-platform.tsx b/packages/web/src/app/routes/create-platform.tsx index 7deaad179ea..e1909517895 100644 --- a/packages/web/src/app/routes/create-platform.tsx +++ b/packages/web/src/app/routes/create-platform.tsx @@ -1,129 +1,9 @@ -import { SAFE_STRING_PATTERN } from '@activepieces/core-utils'; -import { useMutation } from '@tanstack/react-query'; -import { HttpStatusCode } from 'axios'; -import { t } from 'i18next'; -import { useForm, SubmitHandler } from 'react-hook-form'; -import { Navigate } from 'react-router-dom'; +import { AuthLanding } from '@/features/authentication'; -import { platformApi } from '@/api/platforms-api'; -import { Button } from '@/components/ui/button'; -import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { AuthLayout } from '@/features/authentication/components/auth-form-template'; -import { api } from '@/lib/api'; -import { authenticationSession } from '@/lib/authentication-session'; -import { useRedirectAfterLogin } from '@/lib/navigation-utils'; - -type CreatePlatformSchema = { - name: string; +const CreatePlatformPage = () => { + return ; }; -function CreatePlatformForm() { - const redirectAfterLogin = useRedirectAfterLogin(); - const form = useForm({ - defaultValues: { - name: '', - }, - mode: 'onChange', - }); - - const { mutate, isPending } = useMutation({ - mutationFn: platformApi.createPlatform, - onSuccess: (data) => { - authenticationSession.saveResponse(data, false); - redirectAfterLogin(); - }, - onError: (error) => { - const isBadRequest = - api.isError(error) && - error.response?.status === HttpStatusCode.BadRequest; - form.setError('root.serverError', { - message: isBadRequest - ? t('Platform name cannot contain "." or "/"') - : t('Something went wrong, please try again later'), - }); - }, - }); - - const onSubmit: SubmitHandler = (data) => { - form.clearErrors('root.serverError'); - mutate({ name: data.name.trim() }); - }; - - return ( - - - ( - - - - - - )} - /> - {form?.formState?.errors?.root?.serverError && ( - - {form.formState.errors.root.serverError.message} - - )} - - - - ); -} - -function CreatePlatformPage() { - const token = authenticationSession.getToken(); - - if (!token) { - return ; - } - - if (!authenticationSession.isOnboarding()) { - return ; - } - - return ( - -
-

- {t('Create your platform')} -

-

- {t('Give your platform a name to get started.')} -

-
- -
- ); -} +CreatePlatformPage.displayName = 'CreatePlatformPage'; export { CreatePlatformPage }; diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx index 1681ea29c00..e2ec41b2a2f 100644 --- a/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx +++ b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx @@ -2,11 +2,13 @@ import { ErrorCode, isNil } from '@activepieces/core-utils'; import { ApFlagId, CreateOtpRequestBody, + MAX_FULL_NAME_LENGTH, OtpType, TelemetryEventName, } from '@activepieces/shared'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation } from '@tanstack/react-query'; +import { HttpStatusCode } from 'axios'; import { t } from 'i18next'; import { ArrowLeft, @@ -14,18 +16,20 @@ import { CircleAlert, Lightbulb, Mail, + User, } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { Dispatch, SetStateAction, + useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { z } from 'zod'; import { authenticationApi } from '@/api/authentication-api'; @@ -78,7 +82,9 @@ export function AuthDrawerBody({ initialMode }: AuthDrawerBodyProps) { // captured on the way to the code screen. // A stored onboarding token means the member verified their email but never // gave us a name, so resume there wherever they re-enter the app. - const [step, setStep] = useState('method'); + const [step, setStep] = useState( + authenticationSession.isOnboarding() ? 'name' : 'method', + ); const [samlOpen, setSamlOpen] = useState(false); // An invitation arrives as /sign-up?email=…, which that route forwards here // with the search intact. The address is the invitee's, and they have no @@ -162,7 +168,6 @@ function AuthStep({ setCheckEmailNote, invitedEmail, }: AuthStepProps) { - const navigate = useNavigate(); const { data: emailAuthEnabledFlag } = flagsHooks.useFlag( ApFlagId.EMAIL_AUTH_ENABLED, ); @@ -182,6 +187,21 @@ function AuthStep({ const showThirdParty = useShowThirdPartyProviders(); const thirdParty = useThirdPartyAvailability(); + // The confirmation is a beat, not a screen: hold it just long enough to read + // as "that worked" before the name question replaces it. + useEffect(() => { + if (step !== 'verified') { + return; + } + const timer = setTimeout(() => setStep('name'), VERIFIED_HOLD_MS); + return () => clearTimeout(timer); + }, [step, setStep]); + + const abandonOnboarding = useCallback(() => { + authenticationSession.clearSession(); + setStep('method'); + }, [setStep]); + if (samlOpen) { return ( @@ -195,6 +215,26 @@ function AuthStep({ ); } + if (step === 'verified') { + return ( + + + + ); + } + + if (step === 'name') { + return ( + + + + + ); + } + // No email/password auth at all — third-party only. if (!emailAuthEnabled) { return ( @@ -286,7 +326,7 @@ function AuthStep({ setStep('method')} - onNeedsName={() => navigate('/create-platform')} + onNeedsName={() => setStep('verified')} /> ); @@ -569,6 +609,133 @@ function ResetStep() { ); } +function VerifiedFlash() { + return ( +
+ + + + + + + {t('Email verified')} + +
+ ); +} + +function NameStep({ onSessionRejected }: NameStepProps) { + const redirectAfterLogin = useRedirectAfterLogin(); + const form = useForm({ + resolver: zodResolver(FullNameZodSchema), + defaultValues: { fullName: '' }, + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + + const { mutate, isPending } = authMutations.useCompleteSignUp({ + onSuccess: (data) => { + authenticationSession.saveResponse(data, false); + redirectAfterLogin(); + }, + onError: (error) => { + if ( + api.isError(error) && + error.response?.status === HttpStatusCode.Unauthorized + ) { + onSessionRejected(); + return; + } + form.setError('root.serverError', { + message: t('Something went wrong, please try again later'), + }); + }, + }); + + const onSubmit: SubmitHandler = (data) => { + form.clearErrors('root.serverError'); + mutate({ fullName: data.fullName.trim() }); + }; + + return ( +
+ + ( + +
+
+ + +
+ {form.formState.errors.fullName && ( +
+ +

{t('Tell us your name so we know what to call you.')}

+
+ )} +
+
+ )} + /> + {form?.formState?.errors?.root?.serverError && ( + + {form.formState.errors.root.serverError.message} + + )} + + + + ); +} + function CodeStep({ email, onBack, onNeedsName }: CodeStepProps) { const [code, setCode] = useState(''); const [errorMessage, setErrorMessage] = useState(null); @@ -776,6 +943,8 @@ function codeErrorMessage(error: HttpError): string { return t('Something went wrong, please try again later'); } +const VERIFIED_HOLD_MS = 1100; + const PERSONAL_EMAIL_DOMAINS = new Set([ 'gmail.com', 'googlemail.com', @@ -812,6 +981,12 @@ const EmailZodSchema = z.object({ type EmailSchema = z.infer; +const FullNameZodSchema = z.object({ + fullName: z.string().trim().min(1).max(MAX_FULL_NAME_LENGTH), +}); + +type FullNameSchema = z.infer; + type CodeStepProps = { email: string; onBack: () => void; @@ -823,6 +998,10 @@ type EmailStepProps = { onCodeSent: (email: string) => void; }; +type NameStepProps = { + onSessionRejected: () => void; +}; + type AuthDrawerBodyProps = { initialMode: AuthMode; }; @@ -841,6 +1020,6 @@ type AuthStepProps = { invitedEmail: string; }; -type Step = 'method' | 'code' | 'password' | 'reset'; +type Step = 'method' | 'code' | 'verified' | 'name' | 'password' | 'reset'; export type AuthMode = 'signin' | 'signup'; diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx index e3d3d5f6573..5779736a471 100644 --- a/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx +++ b/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx @@ -14,7 +14,9 @@ const NUDGE_STREAK_WINDOW_MS = 700; export function AuthLanding({ initialMode }: AuthLandingProps) { const { setForceLightMode } = useTheme(); const redirectAfterLogin = useRedirectAfterLogin(); - const signedIn = !isNil(authenticationSession.getToken()); + const signedIn = + !isNil(authenticationSession.getToken()) && + !authenticationSession.isOnboarding(); const panelRef = useRef(null); const nudgeRef = useRef<{ lastAt: number; From f74e41b0ce39e8cbb8f004f15beaa1f3ec78251d Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:18:57 +0300 Subject: [PATCH 10/12] feat(auth): refuse throwaway addresses and bot sign-ups (#14707) --- bun.lock | 3 + docs/install/reference/breaking-changes.mdx | 5 + .../reference/environment-variables.mdx | 16 ++ .../dto/passwordless-request.ts | 2 + .../authentication/dto/sign-up-request.ts | 2 + .../shared/src/lib/core/common/telemetry.ts | 9 ++ .../core/shared/src/lib/core/flag/flag.ts | 1 + packages/server/api/package.json | 17 ++- .../authentication.controller.ts | 13 ++ .../authentication/authentication.service.ts | 4 + .../authentication/lib/disposable-email.ts | 55 +++++++ .../src/app/authentication/lib/turnstile.ts | 91 +++++++++++ .../passwordless-auth.service.ts | 10 +- .../server/api/src/app/flags/flag.service.ts | 7 + .../api/src/app/helper/system-validator.ts | 3 + .../api/src/app/helper/system/system-props.ts | 3 + .../api/src/app/helper/system/system.ts | 1 + .../authentication/passwordless-authn.test.ts | 41 +++++ .../authentication/disposable-email.test.ts | 38 +++++ .../unit/app/authentication/turnstile.test.ts | 123 +++++++++++++++ .../web/public/locales/en/translation.json | 4 +- .../auth-landing/auth-drawer-body.tsx | 102 +++++++++++-- .../auth-landing/turnstile-widget.tsx | 141 ++++++++++++++++++ .../components/sign-up-form.tsx | 36 ++++- .../authentication/utils/captcha-utils.ts | 17 +++ 25 files changed, 723 insertions(+), 21 deletions(-) create mode 100644 packages/server/api/src/app/authentication/lib/disposable-email.ts create mode 100644 packages/server/api/src/app/authentication/lib/turnstile.ts create mode 100644 packages/server/api/test/unit/app/authentication/disposable-email.test.ts create mode 100644 packages/server/api/test/unit/app/authentication/turnstile.test.ts create mode 100644 packages/web/src/features/authentication/components/auth-landing/turnstile-widget.tsx create mode 100644 packages/web/src/features/authentication/utils/captcha-utils.ts diff --git a/bun.lock b/bun.lock index 553543f081e..207d15bcd62 100644 --- a/bun.lock +++ b/bun.lock @@ -10684,6 +10684,7 @@ "dayjs": "1.11.9", "decompress": "4.2.1", "deep-equal": "2.2.2", + "disposable-email-domains": "1.0.62", "dotenv": "17.2.3", "eslint-scope": "7.2.2", "fast-xml-parser": "^5.5.6", @@ -15215,6 +15216,8 @@ "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="], + "disposable-email-domains": ["disposable-email-domains@1.0.62", "", {}, "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ=="], + "docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="], "dockerode": ["dockerode@4.0.7", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.6", "protobufjs": "^7.3.2", "tar-fs": "~2.1.2", "uuid": "^10.0.0" } }, "sha512-R+rgrSRTRdU5mH14PZTCPZtW/zw3HDWNTS/1ZAQpL/5Upe/ye5K9WQkIysu4wBoiMwKynsz0a8qWuGsHgEvSAA=="], diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index 16013d8d626..83b2aa16145 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -60,6 +60,10 @@ Nothing on upgrade. Re-check any flow or API integration that filters a Date col ### What has changed? +#### Disposable email addresses can no longer sign up + +Sign-up now refuses addresses from throwaway providers such as `mailinator.com` and `guerrillamail.com`, on both the emailed-code flow and the password form. Federated sign-in (Google, SAML, JWT), managed authentication and SCIM provisioning are unaffected, since those addresses come from an identity provider you already trust. + #### Plans and credits are now managed by our billing service Two license-key endpoints are removed: `GET /v1/license-keys/:licenseKey` and `POST /v1/license-keys/verify`. Applying a key is now `POST /v1/platform-billing/activate`. @@ -76,6 +80,7 @@ When a platform has no credits left, new production runs are recorded with the ` In the AI piece, picking the Activepieces-provided AI now lists only the named tiers (Fast, Expert, Heavy) rather than the full upstream model catalogue. Existing steps keep running on the model they already have, but that model no longer appears in the dropdown, so re-saving the step moves it onto one of the tiers. ### Do you need to take action? +- Only if your members sign up with addresses from a disposable email provider. Set `AP_ALLOW_DISPOSABLE_EMAILS=true` to keep accepting them. - Only if you run the Enterprise edition without a license key. Enter your key so your contracted limits apply instead of the free plan's. - Only if you call `GET /v1/license-keys/:licenseKey` or `POST /v1/license-keys/verify`. Both are removed — use `POST /v1/platform-billing/activate` instead. - Only if you read `SHOW_BILLING_PAGE`, `CAN_BUY_ACTIVE_FLOWS`, `CAN_BUY_AI_CREDITS` or `SHOW_BILLING_LIMITS_ON_SIDEBAR` from `GET /v1/flags`. They no longer exist. diff --git a/docs/install/reference/environment-variables.mdx b/docs/install/reference/environment-variables.mdx index 1b78cb7942c..cee703b8961 100644 --- a/docs/install/reference/environment-variables.mdx +++ b/docs/install/reference/environment-variables.mdx @@ -195,6 +195,22 @@ S3-compatible bucket. --- +### Sign-up protection + +Controls on who may create an account. Both default to the safe behaviour with +no configuration: disposable addresses are refused, and no challenge is served +until you supply Turnstile keys. + +| Variable | Description | Default | +|---|---|---| +| `AP_ALLOW_DISPOSABLE_EMAILS` | Accept addresses from throwaway email providers. | `false` | +| `AP_TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key. Public; served to the sign-in page. | `None` | +| `AP_TURNSTILE_SECRET_KEY` | Cloudflare Turnstile secret key, used to verify a solved challenge. | `None` | + +The challenge is only served when **both** Turnstile variables are set. With +either missing, the sign-in page renders no widget and the server verifies +nothing, so a self-hosted instance needs no Cloudflare account. + ### Email (SMTP) Outbound mail for invitations, notifications, and password resets. diff --git a/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts b/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts index a884ce6e487..236a6919f86 100644 --- a/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts +++ b/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts @@ -2,9 +2,11 @@ import { z } from 'zod' import { EmailType } from '../../user/user' export const MAX_FULL_NAME_LENGTH = 100 +export const MAX_CAPTCHA_TOKEN_LENGTH = 2048 export const RequestEmailCodeRequest = z.object({ email: EmailType, + captchaToken: z.string().trim().min(1).max(MAX_CAPTCHA_TOKEN_LENGTH).optional(), }) export type RequestEmailCodeRequest = z.infer diff --git a/packages/core/shared/src/lib/core/authentication/dto/sign-up-request.ts b/packages/core/shared/src/lib/core/authentication/dto/sign-up-request.ts index a29ac76e8ef..d46862de117 100755 --- a/packages/core/shared/src/lib/core/authentication/dto/sign-up-request.ts +++ b/packages/core/shared/src/lib/core/authentication/dto/sign-up-request.ts @@ -1,6 +1,7 @@ import { ApId, SAFE_STRING_PATTERN } from '@activepieces/core-utils' import { z } from 'zod' import { EmailType, PasswordType } from '../../user/user' +import { MAX_CAPTCHA_TOKEN_LENGTH } from './passwordless-request' export const SignUpRequest = z.object({ email: EmailType, @@ -9,6 +10,7 @@ export const SignUpRequest = z.object({ lastName: z.string().regex(new RegExp(SAFE_STRING_PATTERN)), trackEvents: z.boolean(), newsLetter: z.boolean(), + captchaToken: z.string().trim().min(1).max(MAX_CAPTCHA_TOKEN_LENGTH).optional(), }) export type SignUpRequest = z.infer diff --git a/packages/core/shared/src/lib/core/common/telemetry.ts b/packages/core/shared/src/lib/core/common/telemetry.ts index 4d1b02c0932..bbf7faebb73 100644 --- a/packages/core/shared/src/lib/core/common/telemetry.ts +++ b/packages/core/shared/src/lib/core/common/telemetry.ts @@ -49,6 +49,10 @@ type EmailCodeRejected = { type EmailCodeResendRequested = Record +type CaptchaUnavailable = { + surface: string +} + type QuotaAlert = { percentageUsed: number } @@ -206,6 +210,7 @@ export enum TelemetryEventName { EMAIL_CODE_VERIFIED = 'email.code.verified', EMAIL_CODE_REJECTED = 'email.code.rejected', EMAIL_CODE_RESEND_REQUESTED = 'email.code.resend.requested', + CAPTCHA_UNAVAILABLE = 'captcha.unavailable', QUOTA_ALERT = 'quota.alert', REQUEST_TRIAL_CLICKED = 'request.trial.clicked', REQUEST_TRIAL_SUBMITTED = 'request.trial.submitted', @@ -273,6 +278,10 @@ export type TelemetryEvent = TelemetryEventName.EMAIL_CODE_RESEND_REQUESTED, EmailCodeResendRequested > + | BaseTelemetryEvent< + TelemetryEventName.CAPTCHA_UNAVAILABLE, + CaptchaUnavailable + > | BaseTelemetryEvent | BaseTelemetryEvent< TelemetryEventName.REQUEST_TRIAL_CLICKED, diff --git a/packages/core/shared/src/lib/core/flag/flag.ts b/packages/core/shared/src/lib/core/flag/flag.ts index f4fe42a173f..90d8a0bfff8 100755 --- a/packages/core/shared/src/lib/core/flag/flag.ts +++ b/packages/core/shared/src/lib/core/flag/flag.ts @@ -63,5 +63,6 @@ export enum ApFlagId { PROJECT_RATE_LIMITER_ENABLED = 'PROJECT_RATE_LIMITER_ENABLED', DEFAULT_CONCURRENT_JOBS_LIMIT = 'DEFAULT_CONCURRENT_JOBS_LIMIT', SMTP_CONFIGURED = 'SMTP_CONFIGURED', + TURNSTILE_SITE_KEY = 'TURNSTILE_SITE_KEY', PGVECTOR_AVAILABLE = 'PGVECTOR_AVAILABLE', } diff --git a/packages/server/api/package.json b/packages/server/api/package.json index 860d63ff413..d033eb398ce 100644 --- a/packages/server/api/package.json +++ b/packages/server/api/package.json @@ -5,6 +5,10 @@ "type": "commonjs", "dependencies": { "@1password/sdk": "0.4.0", + "@activepieces/core-execution": "workspace:*", + "@activepieces/core-formula": "workspace:*", + "@activepieces/core-piece-types": "workspace:*", + "@activepieces/core-utils": "workspace:*", "@activepieces/engine": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", @@ -12,10 +16,10 @@ "@activepieces/shared": "workspace:*", "@ai-sdk/amazon-bedrock": "5.0.38", "@ai-sdk/anthropic": "4.0.25", - "@ai-sdk/mcp": "2.0.20", "@ai-sdk/azure": "4.0.26", "@ai-sdk/google": "4.0.29", "@ai-sdk/google-vertex": "5.0.36", + "@ai-sdk/mcp": "2.0.20", "@ai-sdk/openai": "4.0.25", "@ai-sdk/openai-compatible": "3.0.18", "@ai-sdk/provider": "4.0.4", @@ -41,7 +45,6 @@ "@modelcontextprotocol/sdk": "1.27.1", "@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/sdk": "0.2.9", - "posthog-node": "5.38.5", "@sentry/node": "7.120.0", "@smithy/node-http-handler": "4.4.14", "@socket.io/redis-adapter": "8.3.0", @@ -62,6 +65,7 @@ "dayjs": "1.11.9", "decompress": "4.2.1", "deep-equal": "2.2.2", + "disposable-email-domains": "1.0.62", "dotenv": "17.2.3", "eslint-scope": "7.2.2", "fast-xml-parser": "^5.5.6", @@ -87,10 +91,11 @@ "object-sizeof": "2.6.3", "p-limit": "2.3.0", "pg": "8.11.3", - "request-filtering-agent": "3.2.0", + "posthog-node": "5.38.5", "qs": "6.15.2", "redis-memory-server": "0.15.0", "redlock": "5.0.0-beta.2", + "request-filtering-agent": "3.2.0", "samlify": "2.13.0", "semver": "7.6.0", "simple-git": "3.36.0", @@ -103,11 +108,7 @@ "typeorm": "0.3.31", "typeorm-pglite": "0.3.2", "unpdf": "1.4.0", - "zod": "4.3.6", - "@activepieces/core-utils": "workspace:*", - "@activepieces/core-formula": "workspace:*", - "@activepieces/core-piece-types": "workspace:*", - "@activepieces/core-execution": "workspace:*" + "zod": "4.3.6" }, "devDependencies": { "@activepieces/piece-facebook-leads": "workspace:*", diff --git a/packages/server/api/src/app/authentication/authentication.controller.ts b/packages/server/api/src/app/authentication/authentication.controller.ts index 41b5a89363a..23222f205f5 100644 --- a/packages/server/api/src/app/authentication/authentication.controller.ts +++ b/packages/server/api/src/app/authentication/authentication.controller.ts @@ -1,5 +1,6 @@ import { isNil } from '@activepieces/core-utils' import { ApplicationEventName, CompleteSignUpRequest, PrincipalType, RequestEmailCodeRequest, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider, VerifyEmailCodeRequest } from '@activepieces/shared' +import { FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' import { securityAccess } from '../core/security/authorization/fastify-security' @@ -13,6 +14,7 @@ import { telemetry } from '../helper/telemetry.utils' import { platformUtils } from '../platform/platform.utils' import { userService } from '../user/user-service' import { authenticationService } from './authentication.service' +import { turnstile } from './lib/turnstile' import { passwordlessAuthService } from './passwordless-auth.service' export const authenticationController: FastifyPluginAsyncZod = async ( @@ -21,6 +23,11 @@ export const authenticationController: FastifyPluginAsyncZod = async ( app.post('/sign-up', SignUpRequestOptions, async (request) => { const platformId = await platformUtils.getPlatformIdForRequest(request) + await turnstile.assertSolved({ + token: request.body.captchaToken, + remoteIp: clientIp(request), + log: request.log, + }) const signUpResponse = await authenticationService(request.log).signUp({ ...request.body, provider: UserIdentityProvider.EMAIL, @@ -80,6 +87,8 @@ export const authenticationController: FastifyPluginAsyncZod = async ( await passwordlessAuthService(request.log).requestCode({ email: request.body.email, platformId: platformId ?? null, + captchaToken: request.body.captchaToken, + remoteIp: clientIp(request), }) return reply.code(StatusCodes.NO_CONTENT).send() }) @@ -187,6 +196,10 @@ const RequestEmailCodeRequestOptions = { }, } +function clientIp(request: FastifyRequest): string { + return networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)) +} + const VerifyEmailCodeRequestOptions = { config: { security: securityAccess.public(), diff --git a/packages/server/api/src/app/authentication/authentication.service.ts b/packages/server/api/src/app/authentication/authentication.service.ts index 366536d2b24..7cfd2d44cb1 100644 --- a/packages/server/api/src/app/authentication/authentication.service.ts +++ b/packages/server/api/src/app/authentication/authentication.service.ts @@ -9,11 +9,15 @@ import { platformService } from '../platform/platform.service' import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { authenticationUtils } from './authentication-utils' +import { disposableEmail } from './lib/disposable-email' import { otpService } from './otp/otp-service' import { userIdentityService } from './user-identity/user-identity-service' export const authenticationService = (log: FastifyBaseLogger) => ({ async signUp(params: SignUpParams): Promise { + if (params.provider === UserIdentityProvider.EMAIL) { + await disposableEmail.assertMaySignUp({ email: params.email, log }) + } const platformId = params.platformId if (!isNil(platformId)) { diff --git a/packages/server/api/src/app/authentication/lib/disposable-email.ts b/packages/server/api/src/app/authentication/lib/disposable-email.ts new file mode 100644 index 00000000000..213c4040575 --- /dev/null +++ b/packages/server/api/src/app/authentication/lib/disposable-email.ts @@ -0,0 +1,55 @@ +import { ActivepiecesError, ErrorCode } from '@activepieces/core-utils' +import disposableDomains from 'disposable-email-domains' +import wildcardDomains from 'disposable-email-domains/wildcard.json' +import { FastifyBaseLogger } from 'fastify' +import { system } from '../../helper/system/system' +import { AppSystemProp } from '../../helper/system/system-props' +import { userInvitationsService } from '../../user-invitations/user-invitation.service' + +const exactDomains = new Set(disposableDomains) +const suffixDomains: string[] = wildcardDomains + +function domainOf(email: string): string { + const at = email.lastIndexOf('@') + return at < 0 ? '' : email.slice(at + 1).trim().toLowerCase().replace(/\.$/, '') +} + +function isDisposable(email: string): boolean { + const domain = domainOf(email) + if (domain.length === 0) { + return false + } + if (exactDomains.has(domain)) { + return true + } + return suffixDomains.some((suffix) => domain === suffix || domain.endsWith(`.${suffix}`)) +} + +async function assertMaySignUp({ email, log }: AssertMaySignUpParams): Promise { + if (system.getBoolean(AppSystemProp.ALLOW_DISPOSABLE_EMAILS)) { + return + } + if (!isDisposable(email)) { + return + } + const invited = await userInvitationsService(log).hasAnyAcceptedInvitationsForEmail({ email }) + if (invited) { + return + } + throw new ActivepiecesError({ + code: ErrorCode.DOMAIN_NOT_ALLOWED, + params: { + domain: domainOf(email), + }, + }) +} + +export const disposableEmail = { + isDisposable, + assertMaySignUp, +} + +type AssertMaySignUpParams = { + email: string + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/authentication/lib/turnstile.ts b/packages/server/api/src/app/authentication/lib/turnstile.ts new file mode 100644 index 00000000000..e6035f4924b --- /dev/null +++ b/packages/server/api/src/app/authentication/lib/turnstile.ts @@ -0,0 +1,91 @@ +import { ActivepiecesError, ErrorCode, isNil, tryCatch } from '@activepieces/core-utils' +import { safeHttp } from '@activepieces/server-utils' +import { isAxiosError } from 'axios' +import { FastifyBaseLogger } from 'fastify' +import { system } from '../../helper/system/system' +import { AppSystemProp } from '../../helper/system/system-props' + +const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' +const VERIFY_TIMEOUT_MS = 5_000 + +function configuredValue(prop: AppSystemProp): string | undefined { + const raw = system.get(prop)?.trim() + return isNil(raw) || raw.length === 0 ? undefined : raw +} + +function siteKey(): string | undefined { + return isConfigured() ? configuredValue(AppSystemProp.TURNSTILE_SITE_KEY) : undefined +} + +function isConfigured(): boolean { + return !isNil(configuredValue(AppSystemProp.TURNSTILE_SITE_KEY)) + && !isNil(configuredValue(AppSystemProp.TURNSTILE_SECRET_KEY)) +} + +function siteVerifyAnswered(error: unknown): boolean { + return isAxiosError(error) && !isNil(error.response) +} + +function rejected(): ActivepiecesError { + return new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { + message: 'captchaVerificationFailed', + }, + }) +} + +async function assertSolved({ token, remoteIp, log }: AssertSolvedParams): Promise { + if (!isConfigured()) { + return + } + if (isNil(token) || token.length === 0) { + throw rejected() + } + const body = new URLSearchParams({ + secret: configuredValue(AppSystemProp.TURNSTILE_SECRET_KEY) ?? '', + response: token, + ...(isNil(remoteIp) ? {} : { remoteip: remoteIp }), + }) + const { data: response, error } = await tryCatch(() => safeHttp.axios.post( + VERIFY_URL, + body.toString(), + { + timeout: VERIFY_TIMEOUT_MS, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + )) + if (!isNil(error)) { + if (siteVerifyAnswered(error)) { + log.warn({ error }, '[turnstile#assertSolved] siteverify answered with an error status, refusing') + throw rejected() + } + log.warn({ error }, '[turnstile#assertSolved] challenge could not be verified, allowing the request through') + return + } + if (isNil(response)) { + log.warn('[turnstile#assertSolved] challenge could not be verified, allowing the request through') + return + } + if (!response.data.success) { + log.warn({ errors: response.data['error-codes'] }, '[turnstile#assertSolved] challenge rejected') + throw rejected() + } +} + +export const turnstile = { + isConfigured, + siteKey, + assertSolved, +} + +type SiteVerifyResponse = { + success: boolean + 'error-codes'?: string[] +} + +type AssertSolvedParams = { + token: string | undefined + remoteIp: string | undefined + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/authentication/passwordless-auth.service.ts b/packages/server/api/src/app/authentication/passwordless-auth.service.ts index 4cb5594bc9c..4f47579d29c 100644 --- a/packages/server/api/src/app/authentication/passwordless-auth.service.ts +++ b/packages/server/api/src/app/authentication/passwordless-auth.service.ts @@ -12,13 +12,19 @@ import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { authenticationUtils } from './authentication-utils' import { authenticationService } from './authentication.service' +import { disposableEmail } from './lib/disposable-email' import { signupNames } from './lib/signup-names' +import { turnstile } from './lib/turnstile' import { otpService } from './otp/otp-service' import { userIdentityService } from './user-identity/user-identity-service' export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ - async requestCode({ email, platformId }: RequestCodeParams): Promise { + async requestCode({ email, platformId, captchaToken, remoteIp }: RequestCodeParams): Promise { + await turnstile.assertSolved({ token: captchaToken, remoteIp, log }) const existingIdentity = await userIdentityService(log).getIdentityByEmail(email) + if (isNil(existingIdentity)) { + await disposableEmail.assertMaySignUp({ email, log }) + } if (!isNil(platformId)) { await assertPlatformAuthIsOpenTo({ email, platformId, log }) const mayJoin = await mayJoinPlatform({ email, platformId, identity: existingIdentity, log }) @@ -155,6 +161,8 @@ async function mayJoinPlatform({ email, platformId, identity, log }: MayJoinPlat type RequestCodeParams = { email: string platformId: string | null + captchaToken: string | undefined + remoteIp: string | undefined } type CompleteSignUpResult = { diff --git a/packages/server/api/src/app/flags/flag.service.ts b/packages/server/api/src/app/flags/flag.service.ts index 2586e171f61..819d127a8ef 100644 --- a/packages/server/api/src/app/flags/flag.service.ts +++ b/packages/server/api/src/app/flags/flag.service.ts @@ -4,6 +4,7 @@ import { ApEdition, ApFlagId, ExecutionMode, Flag } from '@activepieces/shared' import dayjs from 'dayjs' import { FastifyBaseLogger } from 'fastify' import { In } from 'typeorm' +import { turnstile } from '../authentication/lib/turnstile' import { repoFactory } from '../core/db/repo-factory' import { federatedAuthnService } from '../ee/authentication/federated-authn/federated-authn-service' import { smtpEmailSender } from '../ee/helper/email/email-sender/smtp-email-sender' @@ -285,6 +286,12 @@ export const flagService = (log: FastifyBaseLogger) => ({ created, updated, }, + { + id: ApFlagId.TURNSTILE_SITE_KEY, + value: turnstile.siteKey() ?? null, + created, + updated, + }, { id: ApFlagId.PGVECTOR_AVAILABLE, value: await knowledgeBaseSchema.isVectorExtensionInstalled(), diff --git a/packages/server/api/src/app/helper/system-validator.ts b/packages/server/api/src/app/helper/system-validator.ts index 27f2520eeb4..c7486a140b5 100644 --- a/packages/server/api/src/app/helper/system-validator.ts +++ b/packages/server/api/src/app/helper/system-validator.ts @@ -50,6 +50,7 @@ const systemPropValidators: { [key in SystemProp]: (value: string) => true | string } = { // AppSystemProp + [AppSystemProp.ALLOW_DISPOSABLE_EMAILS]: booleanValidator, [AppSystemProp.ALLOW_OPEN_SIGN_UP]: booleanValidator, [AppSystemProp.EXECUTION_MODE]: enumValidator(Object.values(ExecutionMode)), [AppSystemProp.SKIP_PROJECT_LIMITS_CHECK]: booleanValidator, @@ -76,6 +77,8 @@ const systemPropValidators: { [AppSystemProp.LOKI_USERNAME]: stringValidator, [AppSystemProp.BETTERSTACK_TOKEN]: stringValidator, + [AppSystemProp.TURNSTILE_SECRET_KEY]: stringValidator, + [AppSystemProp.TURNSTILE_SITE_KEY]: stringValidator, [AppSystemProp.BETTERSTACK_HOST]: stringValidator, [AppSystemProp.OTEL_ENABLED]: booleanValidator, [AppSystemProp.OTEL_QUEUE_METRICS_ENABLED]: booleanValidator, diff --git a/packages/server/api/src/app/helper/system/system-props.ts b/packages/server/api/src/app/helper/system/system-props.ts index bf024cc5a67..5730e1ad9e1 100644 --- a/packages/server/api/src/app/helper/system/system-props.ts +++ b/packages/server/api/src/app/helper/system/system-props.ts @@ -4,6 +4,7 @@ import { environmentMigrations } from '@activepieces/server-utils' export type SystemProp = AppSystemProp export enum AppSystemProp { + ALLOW_DISPOSABLE_EMAILS = 'ALLOW_DISPOSABLE_EMAILS', ALLOW_OPEN_SIGN_UP = 'ALLOW_OPEN_SIGN_UP', ALLOWED_EMBED_ORIGINS = 'ALLOWED_EMBED_ORIGINS', API_KEY = 'API_KEY', @@ -118,6 +119,8 @@ export enum AppSystemProp { SMTP_TLS_REJECT_UNAUTHORIZED = 'SMTP_TLS_REJECT_UNAUTHORIZED', SMTP_USERNAME = 'SMTP_USERNAME', TELEMETRY_ENABLED = 'TELEMETRY_ENABLED', + TURNSTILE_SECRET_KEY = 'TURNSTILE_SECRET_KEY', + TURNSTILE_SITE_KEY = 'TURNSTILE_SITE_KEY', TOOL_SEARCH_ENABLED = 'TOOL_SEARCH_ENABLED', TRIGGER_DEFAULT_POLL_INTERVAL = 'TRIGGER_DEFAULT_POLL_INTERVAL', TRIGGER_HOOKS_TIMEOUT_SECONDS = 'TRIGGER_HOOKS_TIMEOUT_SECONDS', diff --git a/packages/server/api/src/app/helper/system/system.ts b/packages/server/api/src/app/helper/system/system.ts index f042586df1d..fbd3a01c6b9 100644 --- a/packages/server/api/src/app/helper/system/system.ts +++ b/packages/server/api/src/app/helper/system/system.ts @@ -34,6 +34,7 @@ const systemPropDefaultValues: Partial> = { [AppSystemProp.WEBHOOK_TIMEOUT_SECONDS]: '30', [AppSystemProp.LOAD_TRANSLATIONS_FOR_DEV_PIECES]: 'false', [AppSystemProp.LOG_LEVEL]: 'info', + [AppSystemProp.ALLOW_DISPOSABLE_EMAILS]: 'false', [AppSystemProp.LOG_PRETTY]: 'false', [AppSystemProp.S3_USE_SIGNED_URLS]: 'false', [AppSystemProp.MAX_FILE_SIZE_MB]: '25', diff --git a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts index 9dc04b92cc0..140731c9fb3 100644 --- a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts +++ b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts @@ -97,6 +97,47 @@ describe('Passwordless Authentication API', () => { expect(identity?.lastName).toBe('') }) + it('refuses a throwaway address and creates nothing', async () => { + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + body: { email: 'someone@mailinator.com' }, + }) + + expect(response?.statusCode).not.toBe(StatusCodes.NO_CONTENT) + expect(response?.json()?.code).toBe('DOMAIN_NOT_ALLOWED') + const identity = await databaseConnection().getRepository('user_identity') + .findOneBy({ email: 'someone@mailinator.com' }) + expect(identity).toBeNull() + }) + + it('lets an invited member through even on a throwaway domain', async () => { + const invited = 'guest@mailinator.com' + await databaseConnection().getRepository('user_invitation').save({ + id: apId(), + email: invited, + type: 'PLATFORM', + platformId: apId(), + status: 'ACCEPTED', + platformRole: PlatformRole.MEMBER, + }) + + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + body: { email: invited }, + }) + + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT) + }) + + it('issues a code with no captcha token when no challenge is configured', async () => { + const statusCode = await requestCode(EMAIL) + + expect(statusCode).toBe(StatusCodes.NO_CONTENT) + expect((await storedOtp(EMAIL))?.value).toMatch(/^[0-9]{6}$/) + }) + it('does not set the USER_CREATED flag before a code is verified', async () => { await requestCode(EMAIL) diff --git a/packages/server/api/test/unit/app/authentication/disposable-email.test.ts b/packages/server/api/test/unit/app/authentication/disposable-email.test.ts new file mode 100644 index 00000000000..19f70fe2629 --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/disposable-email.test.ts @@ -0,0 +1,38 @@ +import { disposableEmail } from '../../../../src/app/authentication/lib/disposable-email' + +describe('disposableEmail', () => { + describe('isDisposable', () => { + it.each([ + 'someone@mailinator.com', + 'someone@guerrillamail.com', + 'someone@10minutemail.com', + ])('rejects the throwaway provider in %s', (email) => { + expect(disposableEmail.isDisposable(email)).toBe(true) + }) + + it.each([ + 'ahmad@activepieces.com', + 'someone@gmail.com', + 'someone@outlook.com', + 'someone@googlemail.com', + ])('accepts the real provider in %s', (email) => { + expect(disposableEmail.isDisposable(email)).toBe(false) + }) + + it('matches a subdomain of a wildcard provider', () => { + const wildcardHit = disposableEmail.isDisposable('someone@mail.mailinator.com') + const unrelated = disposableEmail.isDisposable('someone@mailinator.com.activepieces.com') + + expect(wildcardHit).toBe(true) + expect(unrelated).toBe(false) + }) + + it('ignores case and surrounding whitespace in the domain', () => { + expect(disposableEmail.isDisposable('Someone@MAILINATOR.com ')).toBe(true) + }) + + it('treats an address with no domain as acceptable, leaving that to schema validation', () => { + expect(disposableEmail.isDisposable('not-an-email')).toBe(false) + }) + }) +}) diff --git a/packages/server/api/test/unit/app/authentication/turnstile.test.ts b/packages/server/api/test/unit/app/authentication/turnstile.test.ts new file mode 100644 index 00000000000..69599d1ff09 --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/turnstile.test.ts @@ -0,0 +1,123 @@ +import { safeHttp } from '@activepieces/server-utils' +import { AxiosError, AxiosHeaders } from 'axios' +import { FastifyBaseLogger } from 'fastify' +import { turnstile } from '../../../../src/app/authentication/lib/turnstile' + +function siteVerifyStatus(status: number): AxiosError { + return new AxiosError('siteverify failed', 'ERR_BAD_REQUEST', undefined, undefined, { + status, + statusText: 'Bad Request', + data: {}, + headers: {}, + config: { headers: new AxiosHeaders() }, + }) +} + +const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn() } as unknown as FastifyBaseLogger + +function configure({ site, secret }: { site?: string, secret?: string }): void { + if (site === undefined) { + delete process.env.AP_TURNSTILE_SITE_KEY + } + else { + process.env.AP_TURNSTILE_SITE_KEY = site + } + if (secret === undefined) { + delete process.env.AP_TURNSTILE_SECRET_KEY + } + else { + process.env.AP_TURNSTILE_SECRET_KEY = secret + } +} + +beforeEach(() => { + vi.restoreAllMocks() + configure({}) +}) + +afterAll(() => { + configure({}) +}) + +describe('turnstile', () => { + describe('isConfigured', () => { + it('is off when neither key is set', () => { + expect(turnstile.isConfigured()).toBe(false) + }) + + it('stays off when only one key is set, so a half-configured instance serves no challenge', () => { + configure({ site: 'site-key' }) + expect(turnstile.isConfigured()).toBe(false) + expect(turnstile.siteKey()).toBeUndefined() + + configure({ secret: 'secret-key' }) + expect(turnstile.isConfigured()).toBe(false) + expect(turnstile.siteKey()).toBeUndefined() + }) + + it('treats a blank value as unset, so an empty env line cannot lock sign-up', () => { + configure({ site: ' ', secret: 'secret-key' }) + + expect(turnstile.isConfigured()).toBe(false) + expect(turnstile.siteKey()).toBeUndefined() + }) + + it('is on only when both keys carry a value', () => { + configure({ site: 'site-key', secret: 'secret-key' }) + + expect(turnstile.isConfigured()).toBe(true) + expect(turnstile.siteKey()).toBe('site-key') + }) + }) + + describe('assertSolved', () => { + it('asks nothing of the visitor when no challenge is configured', async () => { + const post = vi.spyOn(safeHttp.axios, 'post') + + await turnstile.assertSolved({ token: undefined, remoteIp: undefined, log }) + + expect(post).not.toHaveBeenCalled() + }) + + it('refuses a missing token once configured', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + + await expect(turnstile.assertSolved({ token: undefined, remoteIp: undefined, log })) + .rejects.toThrow() + }) + + it('refuses a token cloudflare rejects', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + vi.spyOn(safeHttp.axios, 'post').mockResolvedValue({ + data: { success: false, 'error-codes': ['invalid-input-response'] }, + }) + + await expect(turnstile.assertSolved({ token: 'spent', remoteIp: '1.2.3.4', log })) + .rejects.toThrow() + }) + + it('accepts a token cloudflare confirms', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + vi.spyOn(safeHttp.axios, 'post').mockResolvedValue({ data: { success: true } }) + + await expect(turnstile.assertSolved({ token: 'good', remoteIp: '1.2.3.4', log })) + .resolves.toBeUndefined() + }) + + it('lets the request through when cloudflare is unreachable, rather than taking sign-in down', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + vi.spyOn(safeHttp.axios, 'post').mockRejectedValue(new Error('ETIMEDOUT')) + + await expect(turnstile.assertSolved({ token: 'good', remoteIp: undefined, log })) + .resolves.toBeUndefined() + }) + + it('refuses when siteverify answers with an error status, which is an answer rather than an outage', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + vi.spyOn(safeHttp.axios, 'post').mockRejectedValue(siteVerifyStatus(400)) + + await expect(turnstile.assertSolved({ token: 'good', remoteIp: undefined, log })) + .rejects.toThrow() + }) + }) +}) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 1a83dce8010..f9f5d64001c 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2322,5 +2322,7 @@ "Full Name": "Full Name", "Tell us your name so we know what to call you.": "Tell us your name so we know what to call you.", "This names your workspace and how we greet you.": "This names your workspace and how we greet you.", - "What should we call you?": "What should we call you?" + "What should we call you?": "What should we call you?", + "The verification step could not load. Disable your ad blocker for this page, then reload.": "The verification step could not load. Disable your ad blocker for this page, then reload.", + "That verification expired. Please try again.": "That verification expired. Please try again." } diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx index e2ec41b2a2f..ab51a323783 100644 --- a/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx +++ b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx @@ -45,6 +45,7 @@ import { } from '@/components/ui/input-otp'; import { HorizontalSeparatorWithText } from '@/components/ui/separator'; import { authMutations } from '@/features/authentication/hooks/auth-hooks'; +import { captchaUtils } from '@/features/authentication/utils/captcha-utils'; import { flagsHooks } from '@/hooks/flags-hooks'; import { HttpError, api } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; @@ -62,6 +63,8 @@ import { useThirdPartyAvailability, } from '../third-party-logins'; +import { TurnstileWidget, useTurnstileSiteKey } from './turnstile-widget'; + const CODE_LENGTH = 6; const RESEND_COOLDOWN_SECONDS = 60; @@ -95,6 +98,28 @@ export function AuthDrawerBody({ initialMode }: AuthDrawerBodyProps) { ); const [emailForCode, setEmailForCode] = useState(''); const [checkEmailNote, setCheckEmailNote] = useState(false); + const { capture } = useTelemetry(); + const [captchaToken, setCaptchaToken] = useState(); + const [captchaReset, setCaptchaReset] = useState(0); + const [captchaUnavailable, setCaptchaUnavailable] = useState(false); + const handleCaptchaUnavailable = useCallback(() => { + setCaptchaUnavailable(true); + capture({ + name: TelemetryEventName.CAPTCHA_UNAVAILABLE, + payload: { surface: 'code-request' }, + }); + }, [capture]); + // A token is single-use, so every request spends the one in hand and the + // widget has to mint the next. The widget itself lives out here rather than + // inside a step: asking for a code and resending one are two requests, and a + // widget that unmounted with the email step would leave the resend with + // nothing to send. + const spendCaptcha = useCallback(() => { + setCaptchaToken(undefined); + setCaptchaReset((count) => count + 1); + }, []); + const captchaRequired = !isNil(useTurnstileSiteKey()) && !captchaUnavailable; + const challengeApplies = step === 'method' || step === 'code'; return ( @@ -118,9 +143,19 @@ export function AuthDrawerBody({ initialMode }: AuthDrawerBodyProps) { checkEmailNote={checkEmailNote} setCheckEmailNote={setCheckEmailNote} invitedEmail={invitedEmail} + captchaToken={captchaToken} + captchaRequired={captchaRequired} + onCaptchaSpent={spendCaptcha} /> + {challengeApplies && ( + + )} ); } @@ -167,6 +202,9 @@ function AuthStep({ checkEmailNote, setCheckEmailNote, invitedEmail, + captchaToken, + captchaRequired, + onCaptchaSpent, }: AuthStepProps) { const { data: emailAuthEnabledFlag } = flagsHooks.useFlag( ApFlagId.EMAIL_AUTH_ENABLED, @@ -324,6 +362,9 @@ function AuthStep({ return ( setStep('method')} onNeedsName={() => setStep('verified')} @@ -354,6 +395,9 @@ function AuthStep({ )} { setEmailForCode(email); setStep('code'); @@ -445,7 +489,13 @@ function WorkEmailHint() { ); } -function EmailStep({ invitedEmail, onCodeSent }: EmailStepProps) { +function EmailStep({ + invitedEmail, + captchaToken, + captchaRequired, + onCaptchaSpent, + onCodeSent, +}: EmailStepProps) { const form = useForm({ resolver: zodResolver(EmailZodSchema), defaultValues: { email: invitedEmail }, @@ -464,16 +514,21 @@ function EmailStep({ invitedEmail, onCodeSent }: EmailStepProps) { formatUtils.emailRegex.test(email.trim()) && isPersonalEmail(email); const { mutate, isPending } = authMutations.useRequestEmailCode({ - onSuccess: () => onCodeSent(form.getValues().email.trim()), - onError: (error) => + onSuccess: () => { + onCaptchaSpent(); + onCodeSent(form.getValues().email.trim()); + }, + onError: (error) => { + onCaptchaSpent(); form.setError('root.serverError', { message: requestErrorMessage(error), - }), + }); + }, }); const onSubmit: SubmitHandler = (data) => { form.clearErrors('root.serverError'); - mutate({ email: data.email.trim() }); + mutate({ email: data.email.trim(), captchaToken }); }; return ( @@ -508,6 +563,7 @@ function EmailStep({ invitedEmail, onCodeSent }: EmailStepProps) {