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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"security:scan": "npm run security:hardening && npm run security:integrity && npm run security:audit && npm run security:cve",
"qa:central-portal-isolation": "node scripts/validate-central-portal-isolation.mjs",
"qa:auth-broker-negative": "node scripts/test-auth-broker-negative-matrix.mjs",
"qa:multi-tenant": "node scripts/test-generic-multi-tenant-discovery.mjs",
"qa:product-terminology": "node scripts/validate-product-terminology.mjs",
"qa:vercel-spa-routing": "node scripts/validate-vercel-spa-routing.mjs"
},
Expand Down
144 changes: 144 additions & 0 deletions scripts/test-generic-multi-tenant-discovery.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import {
isActiveTenantAdminMembership,
} from '../src/services/tenantAuthorization.js';
import {
resolveStoreSelectorState,
} from '../src/utils/portalHelpers.js';
import {
isAllowedTenantOrigin,
resolveValidatedTenantOrigin,
validateBrokerReturnTo,
} from '../functions/src/authBrokerCore.js';

console.log('--- RUNNING GENERIC MULTI-TENANT DISCOVERY TEST MATRIX ---');

let passedTests = 0;
function pass(testName) {
passedTests++;
console.log(' [PASS] ' + testName);
}

const testUid = 'user-owner-multi';

// 1. Invariant: Active membership evaluation is 100% generic across arbitrary tenant IDs
const arbitraryTenants = [
'tenant-alpha',
'store-beta',
'north-hub-123',
'bp-kallis',
'athens-central',
'store-xyz-999',
];

for (const tenantId of arbitraryTenants) {
const membership = {
uid: testUid,
tenantId,
role: 'OWNER',
status: 'ACTIVE',
};

assert.equal(
isActiveTenantAdminMembership(membership, { uid: testUid, tenantId }),
true,
'Active OWNER membership for arbitrary tenant ' + tenantId + ' must evaluate to true',
);
}
pass('Arbitrary store IDs correctly recognized by generic membership validator');

// 2. Inactive / wrong role / wrong UID memberships strictly rejected for any tenant
const negativeCases = [
{ desc: 'SUSPENDED status', doc: { uid: testUid, tenantId: 'store-1', role: 'OWNER', status: 'SUSPENDED' }, expected: false },
{ desc: 'EXPIRED status', doc: { uid: testUid, tenantId: 'store-1', role: 'OWNER', status: 'EXPIRED' }, expected: false },
{ desc: 'REVOKED status', doc: { uid: testUid, tenantId: 'store-1', role: 'OWNER', status: 'REVOKED' }, expected: false },
{ desc: 'MANAGER role (non-owner)', doc: { uid: testUid, tenantId: 'store-1', role: 'MANAGER', status: 'ACTIVE' }, expected: false },
{ desc: 'STAFF role', doc: { uid: testUid, tenantId: 'store-1', role: 'STAFF', status: 'ACTIVE' }, expected: false },
{ desc: 'UID mismatch', doc: { uid: 'different-uid', tenantId: 'store-1', role: 'OWNER', status: 'ACTIVE' }, expected: false },
{ desc: 'tenantId mismatch', doc: { uid: testUid, tenantId: 'store-wrong', role: 'OWNER', status: 'ACTIVE' }, checkTenantId: 'store-1', expected: false },
];

for (const c of negativeCases) {
const checkTenant = c.checkTenantId || c.doc.tenantId;
assert.equal(
isActiveTenantAdminMembership(c.doc, { uid: testUid, tenantId: checkTenant }),
c.expected,
c.desc + ' must be rejected',
);
}
pass('Strict status, role, and tenant binding invariants enforced across all stores');

// 3. Multi-Store Resolution: User with multiple arbitrary active stores
const mockUser = { uid: testUid, email: 'owner@example.com' };

// Scenario A: User has 0 stores
const state0 = resolveStoreSelectorState({ user: mockUser, tenants: [] });
assert.equal(state0, 'no-access', '0 stores must resolve to no-access state');
pass('0 stores correctly resolves to no-access');

// Scenario B: User has 1 arbitrary store (Tenant A)
const tenantsA = [
{
tenant: { id: 'store-alpha', slug: 'store-alpha', displayName: 'Store Alpha', status: 'ACTIVE' },
membership: { uid: testUid, tenantId: 'store-alpha', role: 'OWNER', status: 'ACTIVE' },
url: 'https://store-alpha.shiftoryx.gr',
},
];
const state1 = resolveStoreSelectorState({ user: mockUser, tenants: tenantsA });
assert.equal(state1, 'ready', '1 store must resolve to ready state');
pass('1 arbitrary store (Store Alpha) correctly resolves');

// Scenario C: User has 2 arbitrary stores (Store Alpha & Store Beta)
const tenantsAB = [
...tenantsA,
{
tenant: { id: 'store-beta', slug: 'store-beta', displayName: 'Store Beta', status: 'ACTIVE' },
membership: { uid: testUid, tenantId: 'store-beta', role: 'OWNER', status: 'ACTIVE' },
url: 'https://store-beta.shiftoryx.gr',
},
];
const state2 = resolveStoreSelectorState({ user: mockUser, tenants: tenantsAB });
assert.equal(state2, 'ready', 'Multiple arbitrary stores must resolve to ready state');
assert.equal(tenantsAB.length, 2, '2 stores must be present');
pass('Multiple arbitrary stores (Store Alpha & Store Beta) correctly resolve');

// Scenario D: User has N arbitrary stores (3+)
const tenantsN = [
...tenantsAB,
{
tenant: { id: 'store-gamma-3', slug: 'store-gamma-3', displayName: 'Store Gamma 3', status: 'ACTIVE' },
membership: { uid: testUid, tenantId: 'store-gamma-3', role: 'OWNER', status: 'ACTIVE' },
url: 'https://store-gamma-3.shiftoryx.gr',
},
];
const state3 = resolveStoreSelectorState({ user: mockUser, tenants: tenantsN });
assert.equal(state3, 'ready', 'N stores must resolve to ready state');
assert.equal(tenantsN.length, 3, '3 stores must be present');
pass('N arbitrary stores correctly resolve without hardcoded constraints');

// 4. Broker returnTo & origin validation for arbitrary stores
const domainFamilies = [
{ id: 'primary', baseDomain: 'shiftoryx.gr', centralDomain: 'shiftoryx.gr' },
{ id: 'legacy', baseDomain: 'homelabshare.gr', centralDomain: 'gas.homelabshare.gr' },
];

for (const slug of ['store-alpha', 'store-beta', 'store-gamma-3', 'arbitrary-corp-99']) {
const origin = 'https://' + slug + '.shiftoryx.gr';
assert.equal(isAllowedTenantOrigin(origin, domainFamilies), true, 'Origin ' + origin + ' must be allowed');

const returnTo = origin + '/app';
const validation = validateBrokerReturnTo({
returnTo,
expectedTenantId: slug,
domainFamilies,
callerOrigin: 'https://shiftoryx.gr',
allowedTenantIds: [slug],
production: true,
});
assert.equal(validation.valid, true, 'Validation for ' + slug + ' must be valid');
assert.equal(validation.tenantId, slug, 'Resolved tenantId must match ' + slug);
assert.equal(validation.allowedTenantOrigin, origin, 'Resolved origin must match ' + origin);
}
pass('Broker returnTo & origin validation passes for all arbitrary stores dynamically');

console.log('\n--- ALL ' + passedTests + ' GENERIC MULTI-TENANT DISCOVERY TESTS PASSED ---');
10 changes: 6 additions & 4 deletions src/components/auth/SelectTenantPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@ export default function SelectTenantPage() {

setStatus('no-access');
setMessage(result.message || TENANT_ACCESS_MESSAGES.noAccess);
} catch {
} catch (err) {
if (!cancelled) {
setStatus('error');
setMessage('Δεν ήταν δυνατή η φόρτωση των καταστημάτων. Δοκιμάστε ξανά αργότερα.');
const cat = err?.category ? ` [${err.category}]` : '';
setMessage(`Δεν ήταν δυνατή η φόρτωση των καταστημάτων. Δοκιμάστε ξανά αργότερα.${cat}`);
}
}
},
Expand Down Expand Up @@ -184,9 +185,10 @@ export default function SelectTenantPage() {
tenantId: tenant.id,
});
window.location.assign(redirectUrl);
} catch {
} catch (err) {
setStatus('error');
setMessage('Δεν ήταν δυνατή η ασφαλής μεταφορά σύνδεσης. Δοκιμάστε ξανά.');
const cat = err?.category ? ` [${err.category}]` : '';
setMessage(`Δεν ήταν δυνατή η ασφαλής μεταφορά σύνδεσης. Δοκιμάστε ξανά.${cat}`);
}
}}
className="rounded-xl bg-slate-950 px-3 py-1.5 text-xs font-bold text-white shadow hover:bg-slate-800 transition"
Expand Down
48 changes: 37 additions & 11 deletions src/firebase/authBrokerService.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,45 @@ import {
functions,
isAuthBrokerEnabled,
isFirebaseConfigured,
} from './config';
} from './config.js';

const SAFE_BROKER_ERROR = 'Δεν ήταν δυνατή η ασφαλής μεταφορά σύνδεσης. Δοκίμασε ξανά.';
const TICKET_PATTERN = /^[a-f0-9]{64}$/i;

export function classifyBrokerError(err, prefix = 'BROKER_CREATE') {
const code = String(err?.code || '').toLowerCase();
const message = String(err?.message || '').toLowerCase();

if (code.includes('unauthenticated') || message.includes('missing-auth') || message.includes('unauthenticated')) {
return `${prefix}_UNAUTHENTICATED`;
}
if (code.includes('permission-denied') || message.includes('permission-denied') || message.includes('forbidden')) {
return `${prefix}_PERMISSION_DENIED`;
}
if (code.includes('invalid-argument') || message.includes('invalid-argument') || message.includes('missing-return-to')) {
return `${prefix}_INVALID_ARGUMENT`;
}
if (code.includes('unavailable') || code.includes('network') || message.includes('network') || message.includes('offline') || message.includes('failed to fetch')) {
return `${prefix}_NETWORK`;
}
if (code.includes('not-found') || message.includes('tenant-not-found')) {
return `${prefix}_NOT_FOUND`;
}
return `${prefix}_INTERNAL`;
}

function assertBrokerReady() {
if (!isAuthBrokerEnabled || !isFirebaseConfigured || !functions) {
throw new Error(SAFE_BROKER_ERROR);
const err = new Error(SAFE_BROKER_ERROR);
err.category = 'BROKER_CREATE_NOT_READY';
throw err;
}
}

function getSafeError() {
return new Error(SAFE_BROKER_ERROR);
function getSafeError(err, prefix = 'BROKER_CREATE') {
const safeErr = new Error(SAFE_BROKER_ERROR);
safeErr.category = classifyBrokerError(err, prefix);
return safeErr;
}

export function hasAuthTicketInUrl() {
Expand Down Expand Up @@ -47,34 +73,34 @@ export async function createTenantAuthTicketRedirect({ returnTo, tenantId }) {
const response = await createAuthTicket({ returnTo, tenantId });
const redirectUrl = String(response.data?.redirectUrl || '');
if (!redirectUrl.startsWith('https://') && !redirectUrl.startsWith('http://localhost')) {
throw getSafeError();
throw getSafeError(new Error('invalid-redirect-url'), 'BROKER_CREATE');
}
return redirectUrl;
} catch {
throw getSafeError();
} catch (err) {
throw getSafeError(err, 'BROKER_CREATE');
}
}

export async function exchangeTenantAuthTicket(ticket) {
assertBrokerReady();

if (!TICKET_PATTERN.test(String(ticket || ''))) {
throw getSafeError();
throw getSafeError(new Error('invalid-ticket-format'), 'BROKER_EXCHANGE');
}

try {
const exchangeAuthTicket = httpsCallable(functions, 'exchangeAuthTicket');
const response = await exchangeAuthTicket({ ticket });
const customToken = String(response.data?.customToken || '');
if (!customToken) throw getSafeError();
if (!customToken) throw getSafeError(new Error('missing-custom-token'), 'BROKER_EXCHANGE');

return {
customToken,
tenantId: String(response.data?.tenantId || ''),
role: String(response.data?.role || ''),
};
} catch {
throw getSafeError();
} catch (err) {
throw getSafeError(err, 'BROKER_EXCHANGE');
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/firebase/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const firebaseConfig = isFirebaseConfigured
const app = firebaseConfig ? initializeApp(firebaseConfig) : null;
const db = app ? getFirestore(app) : null;
const auth = app ? getAuth(app) : null;
const functions = app ? getFunctions(app) : null;
const functions = app ? getFunctions(app, 'us-central1') : null;
const storage = app ? getStorage(app, `gs://${firebaseEnv.storageBucket}`) : null;
let analytics = null;

Expand Down
13 changes: 0 additions & 13 deletions src/repositories/firebase/firebaseTenantMembershipsRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,6 @@ async function listActiveMembershipsForUser(uid) {
memberships = [];
}

// Fallback: If query returned 0 memberships (e.g. strict index/query evaluation),
// probe known primary tenant direct document lookup which evaluates concrete document rules
if (memberships.length === 0) {
try {
const fallbackMembership = await getActiveAdminMembership(uid, 'bp-kallis');
if (fallbackMembership) {
memberships = [fallbackMembership];
}
} catch {
// safe ignore
}
}

return memberships;
}

Expand Down