@@ -4,16 +4,21 @@ import jwt from 'jsonwebtoken';
44import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js' ;
55import { BaseType } from '../../../common/data-injection.tokens.js' ;
66import { Encryptor } from '../../../helpers/encryption/encryptor.js' ;
7+ import { appConfig } from '../../../shared/config/app-config.js' ;
78import {
89 SITENOVA_ENDUSER_AUDIENCE ,
910 SITENOVA_ENDUSER_TOKEN_TTL ,
1011 SitenovaEndUserTokenPayload ,
1112} from '../data-structures/sitenova-site.ds.js' ;
1213
1314// Provisions and uses a per-connection HS256 signing key for generated-site end-user (site visitor)
14- // tokens. The key is stored as a company-scoped UserSecret (encrypted at rest with the app key,
15- // no master-password layer so it can be decrypted unattended on public requests) and never leaves
16- // the backend. Rotating/deleting the secret invalidates every token for that one site.
15+ // tokens, never exposed outside the backend.
16+ //
17+ // When the connection belongs to a company (SaaS), the key is a company-scoped UserSecret —
18+ // encrypted at rest with the app key, no master-password layer so it decrypts unattended on public
19+ // requests; rotating/deleting it invalidates every token for that site. When the connection has NO
20+ // company (self-hosted / single-tenant), we deterministically DERIVE the key from the platform
21+ // secret per connection instead — UserSecret requires a companyId, so storage isn't an option there.
1722@Injectable ( )
1823export class SitenovaEndUserAuthService {
1924 constructor (
@@ -52,19 +57,29 @@ export class SitenovaEndUserAuthService {
5257 return `sitenova:enduser-jwt:${ connectionId } ` ;
5358 }
5459
55- private async resolveCompanyId ( connectionId : string ) : Promise < string > {
60+ private async resolveCompanyIdOrNull ( connectionId : string ) : Promise < string | null > {
5661 const connection = await this . _dbContext . connectionRepository . findOne ( {
5762 where : { id : connectionId } ,
5863 relations : { company : true } ,
5964 } ) ;
60- if ( ! connection || ! connection . company ) {
61- throw new InternalServerErrorException ( 'Connection has no owning company; cannot manage signing key.' ) ;
65+ return connection ?. company ?. id ?? null ;
66+ }
67+
68+ // Deterministic per-connection key for connections with no company. HMAC of the platform secret
69+ // keeps it distinct from the raw JWT_SECRET and isolated per connection, with no storage needed.
70+ private deriveConnectionKey ( connectionId : string ) : string {
71+ const base = appConfig . auth . jwtSecret ;
72+ if ( ! base ) {
73+ throw new InternalServerErrorException ( 'No signing secret configured for SiteNova end-user tokens.' ) ;
6274 }
63- return connection . company . id ;
75+ return crypto . createHmac ( 'sha256' , base ) . update ( `sitenova:enduser: ${ connectionId } ` ) . digest ( 'hex' ) ;
6476 }
6577
6678 private async getSigningKeyOrNull ( connectionId : string ) : Promise < string | null > {
67- const companyId = await this . resolveCompanyId ( connectionId ) ;
79+ const companyId = await this . resolveCompanyIdOrNull ( connectionId ) ;
80+ if ( ! companyId ) {
81+ return this . deriveConnectionKey ( connectionId ) ;
82+ }
6883 const secret = await this . _dbContext . userSecretRepository . findSecretBySlugAndCompanyId (
6984 this . secretSlug ( connectionId ) ,
7085 companyId ,
@@ -76,7 +91,10 @@ export class SitenovaEndUserAuthService {
7691 }
7792
7893 private async getOrCreateSigningKey ( connectionId : string ) : Promise < string > {
79- const companyId = await this . resolveCompanyId ( connectionId ) ;
94+ const companyId = await this . resolveCompanyIdOrNull ( connectionId ) ;
95+ if ( ! companyId ) {
96+ return this . deriveConnectionKey ( connectionId ) ;
97+ }
8098 const slug = this . secretSlug ( connectionId ) ;
8199 const existing = await this . _dbContext . userSecretRepository . findSecretBySlugAndCompanyId ( slug , companyId ) ;
82100 if ( existing ) {
0 commit comments