import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; import { requireOpsAuth } from './auth.js'; export interface IGatewayMachineAuthContext { userId: string; isAdmin: boolean; token?: interfaces.data.IStoredApiToken; credentialId?: string; policy?: interfaces.data.IApiTokenPolicy; gatewayClient?: interfaces.data.IGatewayClient; managedZoneNames: string[]; } export type TGatewayCredentialState = 'candidate' | 'active' | 'manual'; export interface IRequireGatewayMachineAuthOptions { allowCandidate?: boolean; /** Skip managed DNS-domain loading for APIs that do not evaluate hostnames. */ loadManagedZoneNames?: boolean; } export function getGatewayCredentialState( tokenArg: interfaces.data.IStoredApiToken, ): TGatewayCredentialState { const lifecycle = tokenArg.gatewayCredentialLifecycle; if (lifecycle == null) return 'manual'; if (lifecycle.source === 'provisioned' && (lifecycle.state === 'candidate' || lifecycle.state === 'active')) { return lifecycle.state; } throw new plugins.typedrequest.TypedResponseError('gateway-client credential lifecycle is invalid'); } export function normalizeGatewayHostname(hostnameArg: string): string { const candidate = String(hostnameArg || '').trim().toLowerCase().replace(/\.+$/, ''); if (!candidate || candidate.includes('*')) { throw new plugins.typedrequest.TypedResponseError(`invalid hostname: ${hostnameArg}`); } const ascii = plugins.url.domainToASCII(candidate); if (!ascii) { throw new plugins.typedrequest.TypedResponseError(`invalid hostname: ${hostnameArg}`); } return ascii; } export function normalizeGatewayTargetHost(hostArg: string): string { const candidate = String(hostArg || '').trim().toLowerCase().replace(/\.+$/, ''); if (plugins.net.isIP(candidate)) return candidate; const ascii = plugins.url.domainToASCII(candidate); if (!ascii) { throw new plugins.typedrequest.TypedResponseError(`invalid route target host: ${hostArg}`); } return ascii; } export function isHostnameInManagedZone(hostnameArg: string, managedZoneNamesArg: string[]): boolean { const hostname = normalizeGatewayHostname(hostnameArg); return managedZoneNamesArg.some((zoneArg) => { const zone = normalizeGatewayHostname(zoneArg); return hostname === zone || hostname.endsWith(`.${zone}`); }); } export function matchesGatewayHostnamePatterns( hostnameArg: string, patternsArg: string[], managedZoneNamesArg: string[], ): boolean { const hostname = normalizeGatewayHostname(hostnameArg); for (const patternArg of patternsArg) { const rawPattern = String(patternArg || '').trim().toLowerCase().replace(/\.+$/, ''); if (!rawPattern) continue; if (rawPattern === '*') { if (isHostnameInManagedZone(hostname, managedZoneNamesArg)) return true; continue; } const wildcard = rawPattern.startsWith('*.'); const normalizedPattern = normalizeGatewayHostname(wildcard ? rawPattern.slice(2) : rawPattern); if (!wildcard && hostname === normalizedPattern) return true; if (!wildcard || !hostname.endsWith(`.${normalizedPattern}`)) continue; const label = hostname.slice(0, -(normalizedPattern.length + 1)); if (label && !label.includes('.')) return true; } return false; } export function hasOwnedEnabledGatewayRoute( opsServerRefArg: OpsServer, authArg: IGatewayMachineAuthContext, hostnameArg: string, ): boolean { if (authArg.isAdmin) return true; const liveClient = authArg.gatewayClient; if (!liveClient) return false; const hostname = normalizeGatewayHostname(hostnameArg); const routes = opsServerRefArg.dcRouterRef.routeConfigManager?.getMergedRoutes().routes || []; return routes.some((routeArg) => { if (!routeArg.enabled || routeArg.metadata?.ownerType !== 'gatewayClient') return false; if (routeArg.metadata.gatewayClientId !== liveClient.id) return false; if ((routeArg.metadata.gatewayClientType || 'custom') !== liveClient.type) return false; const domains = routeArg.route.match?.domains; const values = Array.isArray(domains) ? domains : typeof domains === 'string' ? domains.split(',') : []; return values.some((domainArg) => { try { return normalizeGatewayHostname(domainArg) === hostname; } catch { return false; } }); }); } export async function requireGatewayMachineAuth( opsServerRefArg: OpsServer, requestArg: { identity?: interfaces.data.IIdentity; apiToken?: string }, scopeArg: 'gateway-clients:read' | 'gateway-clients:write', optionsArg: IRequireGatewayMachineAuthOptions = {}, ): Promise { const auth = await requireOpsAuth(opsServerRefArg, requestArg, { scope: scopeArg, requireAdminIdentity: false, }); const managedZoneNames = optionsArg.loadManagedZoneNames === false ? [] : (await opsServerRefArg.dcRouterRef.dnsManager?.listDomains() || []) .map((domainArg) => domainArg.name); if (auth.isAdmin) { return { userId: auth.userId, isAdmin: true, token: auth.token, policy: auth.token?.policy, managedZoneNames, }; } if (auth.type !== 'apiToken' || auth.token?.policy?.role !== 'gatewayClient') { throw new plugins.typedrequest.TypedResponseError('gateway-client credential required'); } const credentialState = getGatewayCredentialState(auth.token); if (credentialState === 'candidate' && !optionsArg.allowCandidate) { throw new plugins.typedrequest.TypedResponseError('gateway-client credential is awaiting finalization'); } const credentialLifecycle = auth.token.gatewayCredentialLifecycle; const tokenBinding = auth.token.policy.gatewayClient; if (!tokenBinding?.id || !tokenBinding.type || !Number.isSafeInteger(tokenBinding.policyGeneration)) { throw new plugins.typedrequest.TypedResponseError('gateway-client credential binding is incomplete'); } const manager = opsServerRefArg.dcRouterRef.gatewayClientManager; if (!manager) { throw new plugins.typedrequest.TypedResponseError('gateway client management not initialized'); } // Deliberately load the durable policy on every request. Token snapshots are // identity bindings only and never provide current authorization. if (credentialLifecycle && credentialLifecycle.policyGeneration !== tokenBinding.policyGeneration) { throw new plugins.typedrequest.TypedResponseError('gateway-client credential binding is inconsistent'); } const liveClient = credentialLifecycle ? await manager.getClientForProvisionedCredential( tokenBinding.id, tokenBinding.type, credentialLifecycle, ) : await manager.getClient(tokenBinding.id); if (!liveClient || !liveClient.enabled) { throw new plugins.typedrequest.TypedResponseError('gateway client is missing or disabled'); } if (liveClient.type !== tokenBinding.type) { throw new plugins.typedrequest.TypedResponseError('gateway-client credential type does not match live policy'); } if (liveClient.policyGeneration !== tokenBinding.policyGeneration) { throw new plugins.typedrequest.TypedResponseError('gateway-client credential policy generation is stale'); } const livePolicy: interfaces.data.IApiTokenPolicy = { role: 'gatewayClient', scopes: ['gateway-clients:read', 'gateway-clients:write'], gatewayClient: { type: liveClient.type, id: liveClient.id, policyGeneration: liveClient.policyGeneration, }, hostnamePatterns: structuredClone(liveClient.hostnamePatterns), allowedRouteTargets: structuredClone(liveClient.allowedRouteTargets), capabilities: structuredClone(liveClient.capabilities), }; return { userId: auth.userId, isAdmin: false, token: auth.token, credentialId: auth.token.id, policy: livePolicy, gatewayClient: liveClient, managedZoneNames, }; }