diff --git a/backend/src/entities/user-secret/repository/user-secret-repository.extension.ts b/backend/src/entities/user-secret/repository/user-secret-repository.extension.ts index e381da272..61e37c44a 100644 --- a/backend/src/entities/user-secret/repository/user-secret-repository.extension.ts +++ b/backend/src/entities/user-secret/repository/user-secret-repository.extension.ts @@ -1,8 +1,28 @@ -import { FindOptionsWhere, Like } from 'typeorm'; +import { DeepPartial, FindOptionsWhere, Like } from 'typeorm'; import { UserSecretEntity } from '../user-secret.entity.js'; import { IUserSecretRepository } from './user-secret-repository.interface.js'; export const userSecretRepositoryExtension: IUserSecretRepository = { + /** + * Atomically provisions a secret the first time it is needed and returns whichever row exists + * afterwards — the one just inserted or the one a concurrent caller inserted first. + * `user_secrets (companyId, slug)` is UNIQUE; a read-then-insert races with itself when several + * requests provision the same slug at once (e.g. the universal-backend instances all fetching a + * fresh site's end-user signing key on its first page load) and the losers fail with + * `duplicate key value violates unique constraint "IDX_f39a47aac503fe096b0c77f2b3"`. + * `INSERT … ON CONFLICT DO NOTHING` + re-read makes every caller land on the single row. + */ + async createSecretUnlessExists( + values: DeepPartial & { slug: string; companyId: string }, + ): Promise { + await this.createQueryBuilder().insert().into(UserSecretEntity).values(values).orIgnore().execute(); + const secret = await this.findOne({ where: { slug: values.slug, companyId: values.companyId } }); + if (!secret) { + throw new Error(`Secret "${values.slug}" is missing right after its creation.`); + } + return secret; + }, + async findSecretBySlugAndCompanyId(slug: string, companyId: string): Promise { return this.findOne({ where: { diff --git a/backend/src/entities/user-secret/repository/user-secret-repository.interface.ts b/backend/src/entities/user-secret/repository/user-secret-repository.interface.ts index 865c2b099..0a2b92e0f 100644 --- a/backend/src/entities/user-secret/repository/user-secret-repository.interface.ts +++ b/backend/src/entities/user-secret/repository/user-secret-repository.interface.ts @@ -1,7 +1,11 @@ -import { Repository } from 'typeorm'; +import { DeepPartial, Repository } from 'typeorm'; import { UserSecretEntity } from '../user-secret.entity.js'; export interface IUserSecretRepository { + createSecretUnlessExists( + this: Repository, + values: DeepPartial & { slug: string; companyId: string }, + ): Promise; findSecretBySlugAndCompanyId( this: Repository, slug: string, diff --git a/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts b/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts index d455f7f55..b6889410f 100644 --- a/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts +++ b/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts @@ -118,16 +118,18 @@ export class SitenovaEndUserAuthService { if (existing) { return Encryptor.decryptData(existing.encryptedValue); } - const key = crypto.randomBytes(32).toString('hex'); - const secret = this._dbContext.userSecretRepository.create({ + // First use of this connection's key. Concurrent first uses (the universal-backend replicas + // all fetching a fresh site's credentials on its first page load) all miss the read above, so + // the insert is conflict-tolerant and the key handed back is whichever row won — never a + // locally generated key that the database did not keep. + const secret = await this._dbContext.userSecretRepository.createSecretUnlessExists({ slug, - encryptedValue: Encryptor.encryptData(key), + encryptedValue: Encryptor.encryptData(crypto.randomBytes(32).toString('hex')), companyId, expiresAt: null, masterEncryption: false, masterHash: null, }); - await this._dbContext.userSecretRepository.save(secret); - return key; + return Encryptor.decryptData(secret.encryptedValue); } } diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-sitenova-microservice-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-sitenova-microservice-e2e.test.ts index ee38b8914..ae6295b5c 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-sitenova-microservice-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-sitenova-microservice-e2e.test.ts @@ -9,8 +9,11 @@ import cookieParser from 'cookie-parser'; import jwt from 'jsonwebtoken'; import nock from 'nock'; import request from 'supertest'; +import { DataSource } from 'typeorm'; import { ApplicationModule } from '../../../src/app.module.js'; +import { BaseType } from '../../../src/common/data-injection.tokens.js'; import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { UserSecretEntity } from '../../../src/entities/user-secret/user-secret.entity.js'; import { TableActionEventEnum } from '../../../src/enums/table-action-event-enum.js'; import { TableActionMethodEnum } from '../../../src/enums/table-action-method-enum.js'; import { TableActionTypeEnum } from '../../../src/enums/table-action-type.enum.js'; @@ -536,3 +539,64 @@ test.serial(`${currentTest} rejects a missing microservice JWT (401) and a malfo .set('Accept', 'application/json'); t.is(noKeys.status, 400); }); + +currentTest = 'GET /internal/sitenova/connection/:connectionId (internal, microservice JWT)'; + +// The universal-backend asks for a site's credentials + end-user signing key on every cold cache, +// and a generated site's first page load fires several such requests at once (one per replica, +// one per parallel API call). The key is provisioned lazily on the first request, so the first +// burst must be race-safe: every caller gets 200 and the SAME key, and exactly one +// `sitenova:enduser-jwt:` secret exists afterwards — not the +// `duplicate key value violates unique constraint "IDX_f39a47aac503fe096b0c77f2b3"` 500 the +// read-then-insert used to produce for every loser of the race. +test.serial(`${currentTest} concurrent first requests provision one signing key`, async (t) => { + const { connectionId, userId } = await createConnectionAndTable(); + + const responses = await Promise.all( + Array.from({ length: 8 }, () => + request(app.getHttpServer()) + .get(`/internal/sitenova/connection/${connectionId}`) + .set('Authorization', microserviceAuthHeader()) + .set('Accept', 'application/json'), + ), + ); + + for (const res of responses) { + t.is(res.status, 200, res.text); + t.is(res.body.connection.id, connectionId); + t.is(typeof res.body.endUserJwtKey, 'string'); + } + const keys = new Set(responses.map((res) => res.body.endUserJwtKey as string)); + t.is(keys.size, 1, 'every concurrent request resolved to the single stored signing key'); + const [key] = keys; + + const dataSource = app.get(BaseType.DATA_SOURCE); + const storedSecrets = await dataSource + .getRepository(UserSecretEntity) + .count({ where: { slug: `sitenova:enduser-jwt:${connectionId}` } }); + t.is(storedSecrets, 1, 'the signing key is stored once'); + + // The key handed out is the one the core itself signs with: a visitor token minted by the + // core's register route verifies with it, so tokens issued by either service stay interchangeable. + const usersTable = await createUsersTable(connectionId, userId); + const visitorToken = await registerAndGetToken(connectionId, usersTable); + const decoded = jwt.verify(visitorToken, key, { algorithms: ['HS256'], audience: 'sitenova:enduser' }) as { + cid: string; + }; + t.is(decoded.cid, connectionId); + + const again = await request(app.getHttpServer()) + .get(`/internal/sitenova/connection/${connectionId}`) + .set('Authorization', microserviceAuthHeader()) + .set('Accept', 'application/json'); + t.is(again.status, 200); + t.is(again.body.endUserJwtKey, key, 'a later request returns the stored key, not a fresh one'); +}); + +test.serial(`${currentTest} is rejected (401) without a microservice JWT`, async (t) => { + const { connectionId } = await createConnectionAndTable(); + const res = await request(app.getHttpServer()) + .get(`/internal/sitenova/connection/${connectionId}`) + .set('Accept', 'application/json'); + t.is(res.status, 401); +});