Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<UserSecretEntity> & { slug: string; companyId: string },
): Promise<UserSecretEntity> {
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<UserSecretEntity | null> {
return this.findOne({
where: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UserSecretEntity>,
values: DeepPartial<UserSecretEntity> & { slug: string; companyId: string },
): Promise<UserSecretEntity>;
findSecretBySlugAndCompanyId(
this: Repository<UserSecretEntity>,
slug: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:<connectionId>` 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<DataSource>(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);
});
Loading