From 2db0839930395d4e75f4a6fe9fc5f0193391f2b5 Mon Sep 17 00:00:00 2001 From: commanderxgr Date: Sat, 5 Sep 2026 13:51:50 +0300 Subject: [PATCH] fix(auth): reconcile broker client config and generic multi-tenant discovery --- package.json | 1 + .../test-generic-multi-tenant-discovery.mjs | 144 ++++++++++++++++++ src/components/auth/SelectTenantPage.jsx | 10 +- src/firebase/authBrokerService.js | 48 ++++-- src/firebase/config.js | 2 +- .../firebaseTenantMembershipsRepository.js | 13 -- 6 files changed, 189 insertions(+), 29 deletions(-) create mode 100644 scripts/test-generic-multi-tenant-discovery.mjs diff --git a/package.json b/package.json index 1d80f87..bf86bc8 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/test-generic-multi-tenant-discovery.mjs b/scripts/test-generic-multi-tenant-discovery.mjs new file mode 100644 index 0000000..213640c --- /dev/null +++ b/scripts/test-generic-multi-tenant-discovery.mjs @@ -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 ---'); diff --git a/src/components/auth/SelectTenantPage.jsx b/src/components/auth/SelectTenantPage.jsx index f48df22..1a0471a 100644 --- a/src/components/auth/SelectTenantPage.jsx +++ b/src/components/auth/SelectTenantPage.jsx @@ -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}`); } } }, @@ -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" diff --git a/src/firebase/authBrokerService.js b/src/firebase/authBrokerService.js index a27bf06..6c34f00 100644 --- a/src/firebase/authBrokerService.js +++ b/src/firebase/authBrokerService.js @@ -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() { @@ -47,11 +73,11 @@ 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'); } } @@ -59,22 +85,22 @@ 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'); } } diff --git a/src/firebase/config.js b/src/firebase/config.js index 1ba6860..7b68d3a 100644 --- a/src/firebase/config.js +++ b/src/firebase/config.js @@ -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; diff --git a/src/repositories/firebase/firebaseTenantMembershipsRepository.js b/src/repositories/firebase/firebaseTenantMembershipsRepository.js index 4ef4c13..6c72255 100644 --- a/src/repositories/firebase/firebaseTenantMembershipsRepository.js +++ b/src/repositories/firebase/firebaseTenantMembershipsRepository.js @@ -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; }