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
155 changes: 89 additions & 66 deletions functions/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,80 +276,103 @@ export const exchangeAuthTicket = onCall(
region: process.env.AUTH_BROKER_FUNCTIONS_REGION || 'us-central1',
},
async (request) => {
const config = getBrokerConfig();
const origin = getRequestOrigin(request);
const isDynamicTenantOrigin = isAllowedTenantOrigin(origin, config.domainFamilies);
const isStaticTenantOrigin = isAllowedBrokerOrigin(origin, config.tenantOrigins);
if (!isDynamicTenantOrigin && !isStaticTenantOrigin) {
deny('invalid-tenant-origin');
}

const ticketValidation = validateTicketFormat(request.data?.ticket);
if (!ticketValidation.valid) invalid('invalid-ticket');

const originUrl = new URL(origin);
const originTenantId = resolveTenantIdFromHostname({
hostname: originUrl.hostname,
baseDomain: config.baseDomain,
centralDomain: config.centralDomain,
domainFamilies: config.domainFamilies,
});
if (!originTenantId) deny('invalid-tenant-host');

const ticketHash = hashAuthTicket(ticketValidation.ticket);
const db = getDb();
const ticketRef = db.doc(`authTickets/${ticketHash}`);
let consumedTicket = null;

await db.runTransaction(async (transaction) => {
const snapshot = await transaction.get(ticketRef);
if (!snapshot.exists) deny('ticket-not-found');

const ticket = snapshot.data();
if (ticket.status !== AUTH_TICKET_STATUS.pending || ticket.usedAt) deny('ticket-used');
if (ticket.expiresAt?.toMillis?.() <= Date.now()) deny('ticket-expired');
if (ticket.allowedTenantOrigin !== origin) deny('ticket-origin-mismatch');
if (ticket.tenantId !== originTenantId) deny('ticket-tenant-mismatch');

const platformAdminRef = db.doc(`platformAdmins/${ticket.uid}`);
const platformAdminSnapshot = await transaction.get(platformAdminRef);
if (platformAdminSnapshot.exists && platformAdminSnapshot.data()?.status === 'ACTIVE') {
deny('platform-admin-tenant-access-forbidden');
let currentStage = 'init';
const correlationId = randomUUID();
try {
currentStage = 'origin_validation';
const config = getBrokerConfig();
const origin = getRequestOrigin(request);
const isDynamicTenantOrigin = isAllowedTenantOrigin(origin, config.domainFamilies);
const isStaticTenantOrigin = isAllowedBrokerOrigin(origin, config.tenantOrigins);
if (!isDynamicTenantOrigin && !isStaticTenantOrigin) {
deny('invalid-tenant-origin');
}

const membershipRef = db.doc(`tenantMemberships/${ticket.uid}_${ticket.tenantId}`);
const membershipSnapshot = await transaction.get(membershipRef);
if (!membershipSnapshot.exists) deny('missing-membership');
const membership = membershipSnapshot.data();
if (membership.uid !== ticket.uid || membership.tenantId !== ticket.tenantId) {
deny('membership-mismatch');
}
if (!isActiveBrokerMembership(membership)) {
deny('inactive-or-invalid-membership');
}
if (membership.role !== ticket.role) deny('membership-role-changed');
currentStage = 'ticket_validation';
const ticketValidation = validateTicketFormat(request.data?.ticket);
if (!ticketValidation.valid) invalid('invalid-ticket');

transaction.update(ticketRef, {
status: AUTH_TICKET_STATUS.used,
usedAt: FieldValue.serverTimestamp(),
usedByOrigin: origin,
currentStage = 'tenant_resolution';
const originUrl = new URL(origin);
const originTenantId = resolveTenantIdFromHostname({
hostname: originUrl.hostname,
baseDomain: config.baseDomain,
centralDomain: config.centralDomain,
domainFamilies: config.domainFamilies,
});
if (!originTenantId) deny('invalid-tenant-host');

consumedTicket = ticket;
});
currentStage = 'ticket_lookup_and_consume';
const ticketHash = hashAuthTicket(ticketValidation.ticket);
const db = getDb();
const ticketRef = db.doc(`authTickets/${ticketHash}`);
let consumedTicket = null;

await db.runTransaction(async (transaction) => {
const snapshot = await transaction.get(ticketRef);
if (!snapshot.exists) deny('ticket-not-found');

const ticket = snapshot.data();
if (ticket.status !== AUTH_TICKET_STATUS.pending || ticket.usedAt) deny('ticket-used');
if (ticket.expiresAt?.toMillis?.() <= Date.now()) deny('ticket-expired');
if (ticket.allowedTenantOrigin !== origin) deny('ticket-origin-mismatch');
if (ticket.tenantId !== originTenantId) deny('ticket-tenant-mismatch');

const platformAdminRef = db.doc(`platformAdmins/${ticket.uid}`);
const platformAdminSnapshot = await transaction.get(platformAdminRef);
if (platformAdminSnapshot.exists && platformAdminSnapshot.data()?.status === 'ACTIVE') {
deny('platform-admin-tenant-access-forbidden');
}

const membershipRef = db.doc(`tenantMemberships/${ticket.uid}_${ticket.tenantId}`);
const membershipSnapshot = await transaction.get(membershipRef);
if (!membershipSnapshot.exists) deny('missing-membership');
const membership = membershipSnapshot.data();
if (membership.uid !== ticket.uid || membership.tenantId !== ticket.tenantId) {
deny('membership-mismatch');
}
if (!isActiveBrokerMembership(membership)) {
deny('inactive-or-invalid-membership');
}
if (membership.role !== ticket.role) deny('membership-role-changed');

transaction.update(ticketRef, {
status: AUTH_TICKET_STATUS.used,
usedAt: FieldValue.serverTimestamp(),
usedByOrigin: origin,
});

consumedTicket = ticket;
});

if (!consumedTicket) unavailable('ticket-consume-failed');
if (!consumedTicket) unavailable('ticket-consume-failed');

const customToken = await getAuth().createCustomToken(consumedTicket.uid, {
tenantId: consumedTicket.tenantId,
role: consumedTicket.role,
});
currentStage = 'custom_token_creation';
const customToken = await getAuth().createCustomToken(consumedTicket.uid, {
tenantId: consumedTicket.tenantId,
role: consumedTicket.role,
});

return {
customToken,
tenantId: consumedTicket.tenantId,
role: consumedTicket.role,
};
currentStage = 'response_build';
return {
customToken,
tenantId: consumedTicket.tenantId,
role: consumedTicket.role,
};
} catch (err) {
if (err instanceof HttpsError) {
throw err;
}
logger.error({
event: 'auth_broker_exchange_failed',
stage: currentStage,
reason: err?.message || 'unknown',
correlationId,
});
throw new HttpsError('internal', 'Δεν ήταν δυνατή η ασφαλής μεταφορά σύνδεσης.', {
reason: 'internal-exchange-failure',
});
}
},
);

Expand Down
39 changes: 34 additions & 5 deletions src/components/auth/AuthTicketCallback.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,21 @@ import {
} from '../../firebase/authBrokerService';
import { buildCentralLoginUrl, createCurrentReturnToUrl } from '../../services/tenantAccessService';

function classifyCustomTokenSignInError(err) {
const code = String(err?.code || '').toLowerCase();
const message = String(err?.message || '').toLowerCase();
if (code.includes('invalid-custom-token') || message.includes('invalid-custom-token')) {
return 'CUSTOM_TOKEN_SIGNIN_INVALID_CUSTOM_TOKEN';
}
if (code.includes('network') || message.includes('network') || message.includes('failed to fetch')) {
return 'CUSTOM_TOKEN_SIGNIN_NETWORK';
}
return 'CUSTOM_TOKEN_SIGNIN_INTERNAL';
}

export default function AuthTicketCallback() {
const [status, setStatus] = useState('idle');
const [diagnosticCode, setDiagnosticCode] = useState('');

useEffect(() => {
let cancelled = false;
Expand All @@ -18,14 +31,24 @@ export default function AuthTicketCallback() {
setStatus('exchanging');

exchangeTenantAuthTicket(ticket)
.then(({ customToken }) => authRepository.signInWithBrokerCustomToken({ customToken }))
.then(({ customToken }) => {
return authRepository.signInWithBrokerCustomToken({ customToken }).catch((signInErr) => {
const diag = classifyCustomTokenSignInError(signInErr);
const customErr = new Error(diag);
customErr.category = diag;
throw customErr;
});
})
.then(() => {
if (!cancelled && typeof window !== 'undefined') {
window.location.assign('/app');
}
})
.catch(() => {
if (!cancelled) setStatus('error');
.catch((err) => {
if (!cancelled) {
setStatus('error');
setDiagnosticCode(err?.category || 'BROKER_EXCHANGE_INTERNAL');
}
});

return () => {
Expand All @@ -36,12 +59,18 @@ export default function AuthTicketCallback() {
if (status === 'idle') return null;

return (
<div className="fixed inset-x-4 top-4 z-[80] mx-auto max-w-xl rounded-2xl border border-cyan-300/45 bg-slate-950/95 p-4 text-sm font-semibold text-slate-100 shadow-2xl shadow-cyan-950/40">
<div
className="fixed inset-x-4 top-4 z-[80] mx-auto max-w-xl rounded-2xl border border-cyan-300/45 bg-slate-950/95 p-4 text-sm font-semibold text-slate-100 shadow-2xl shadow-cyan-950/40"
data-diagnostic={diagnosticCode}
>
{status === 'exchanging' ? (
<p>Ολοκλήρωση ασφαλούς σύνδεσης...</p>
) : (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p>{SAFE_BROKER_ERROR}</p>
<p>
{SAFE_BROKER_ERROR}
{diagnosticCode ? ` [${diagnosticCode}]` : ''}
</p>
<a
href={buildCentralLoginUrl(createCurrentReturnToUrl())}
className="rounded-xl bg-brand-500 px-3 py-2 text-center text-xs font-black text-white hover:bg-brand-600"
Expand Down