925 lines
34 KiB
TypeScript
925 lines
34 KiB
TypeScript
import { getRlsExecutionContext } from "@hivegrid/db/rls-context.server";
|
|
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
|
|
import { decryptTenantField, encryptTenantField } from "@hivegrid/db/crypto/tenant-envelope.server";
|
|
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
|
|
import {
|
|
discoverKwpMandants,
|
|
isValidKwpContextCode,
|
|
KWP_APP_ID,
|
|
KWP_LEGACY_MANDANT_SELECTION_KIND,
|
|
KWP_MANDANT_SELECTION_HEALTH_KEY,
|
|
KWP_MANDANT_SELECTION_KIND,
|
|
KWP_OAUTH_PENDING_HEALTH_KEY,
|
|
KWP_OAUTH_RESERVATION_KIND,
|
|
KwpError,
|
|
parseKwpOAuthReservationBundle,
|
|
parseKwpPendingAttemptLifecycle,
|
|
type KwpCredentials,
|
|
type KwpMandantBinding,
|
|
type KwpOAuthReservationBundle,
|
|
type KwpRestorableConnectionSnapshot,
|
|
type KwpRestorableConnectionStatus,
|
|
} from "@hivegrid/integrations";
|
|
|
|
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
|
|
import { activateOAuthConnection, AppsEngineServiceError } from "~/utils/integrations/appsEngineService.server";
|
|
|
|
import { buildKwpAuthorizationUrl, exchangeKwpAuthorizationCode, KwpOidcClientError, readKwpOidcRuntimeConfig } from "./kwpOidcClient.server";
|
|
import {
|
|
createKwpOAuthChallenge,
|
|
KWP_OAUTH_CHALLENGE_TTL_MS,
|
|
type KwpOAuthChallengePayload,
|
|
} from "./kwpOAuthChallenge.server";
|
|
|
|
const KWP_CONNECTION_LABEL = "KWP";
|
|
const KWP_SLOT_LOCK_SALT = 32_001;
|
|
const KWP_NOT_CONNECTED_HEALTH_KEY = "integrations.health.notConnected";
|
|
const MAX_DISCOVERED_MANDANTS = 100;
|
|
const MAX_MANDANT_LABEL_LENGTH = 256;
|
|
|
|
type KwpUnconfirmedCredentials = Omit<KwpCredentials, "mandantBinding"> & { readonly mandantBinding?: never };
|
|
|
|
type KwpMandantOption = {
|
|
readonly id: number;
|
|
readonly code: string;
|
|
readonly label: string;
|
|
};
|
|
|
|
type KwpMandantSelectionBundle = {
|
|
readonly kind: typeof KWP_MANDANT_SELECTION_KIND;
|
|
readonly previous: KwpRestorableConnectionSnapshot | null;
|
|
readonly oauth: KwpUnconfirmedCredentials;
|
|
readonly discovery: { readonly kind: "available"; readonly options: readonly KwpMandantOption[] } | { readonly kind: "manual" };
|
|
readonly issuedAt: string;
|
|
};
|
|
|
|
type PendingKwpMandantSelection = {
|
|
readonly connectionId: string;
|
|
readonly version: string;
|
|
readonly credentialsCiphertext: string;
|
|
readonly tokenExpiresAt: Date;
|
|
readonly bundle: KwpMandantSelectionBundle;
|
|
};
|
|
|
|
type PendingKwpOAuthReservation = {
|
|
readonly connectionId: string;
|
|
readonly version: string;
|
|
readonly credentialsCiphertext: string;
|
|
readonly tokenExpiresAt: Date;
|
|
readonly bundle: KwpOAuthReservationBundle;
|
|
};
|
|
|
|
type RestorablePendingKwpAttempt = {
|
|
readonly connectionId: string;
|
|
readonly version: string;
|
|
readonly credentialsCiphertext: string;
|
|
readonly tokenExpiresAt: Date;
|
|
readonly healthMessageKey: typeof KWP_OAUTH_PENDING_HEALTH_KEY | typeof KWP_MANDANT_SELECTION_HEALTH_KEY;
|
|
readonly previous: KwpRestorableConnectionSnapshot | null;
|
|
};
|
|
|
|
export class KwpConnectError extends Error {
|
|
readonly code:
|
|
| "not_configured"
|
|
| "invalid_state"
|
|
| "slot_conflict"
|
|
| "authorization_failed"
|
|
| "exchange_failed"
|
|
| "connection_failed"
|
|
| "persistence_failed"
|
|
| "invalid_selection"
|
|
| "selection_expired";
|
|
readonly diagnosticCode?: string;
|
|
|
|
constructor(code: KwpConnectError["code"], options?: { readonly diagnosticCode?: string }) {
|
|
super(code);
|
|
this.name = "KwpConnectError";
|
|
this.code = code;
|
|
this.diagnosticCode = options?.diagnosticCode;
|
|
}
|
|
}
|
|
|
|
export type BeginKwpOAuthConnectionResult = {
|
|
readonly authorizationUrl: string;
|
|
readonly setCookie: string;
|
|
};
|
|
|
|
export type CompleteKwpOAuthConnectionResult = {
|
|
readonly tenantId: string;
|
|
readonly tenantSlug: string;
|
|
readonly connectionId: string;
|
|
};
|
|
|
|
export type KwpMandantSelectionView = {
|
|
readonly connectionId: string;
|
|
readonly customerCode: string;
|
|
/** Exact DataConnection.updatedAt CAS fence for the selection submission. */
|
|
readonly version: string;
|
|
readonly mode: "available" | "manual";
|
|
readonly options: readonly KwpMandantOption[];
|
|
};
|
|
|
|
/** Reserve/reuse the tenant's one KWP slot and create a one-time OIDC challenge. */
|
|
export async function beginKwpOAuthConnection(input: {
|
|
readonly tenantId: string;
|
|
readonly tenantSlug: string;
|
|
readonly userId: string;
|
|
readonly customerCode: string;
|
|
}): Promise<BeginKwpOAuthConnectionResult> {
|
|
assertBoundIdentity(input.tenantId, input.userId);
|
|
|
|
let runtimeConfig: ReturnType<typeof readKwpOidcRuntimeConfig>;
|
|
try {
|
|
runtimeConfig = readKwpOidcRuntimeConfig();
|
|
} catch {
|
|
throw new KwpConnectError("not_configured");
|
|
}
|
|
if (runtimeConfig.hiveGridTenantId !== input.tenantId || !isValidKwpContextCode(input.customerCode) || input.customerCode !== runtimeConfig.customerCode) {
|
|
throw new KwpConnectError("not_configured");
|
|
}
|
|
|
|
const reservation = await ensureKwpConnectionSlot(input.tenantId, input.userId, input.customerCode);
|
|
try {
|
|
const challenge = createKwpOAuthChallenge({
|
|
tenantId: input.tenantId,
|
|
tenantSlug: input.tenantSlug,
|
|
userId: input.userId,
|
|
connectionId: reservation.connectionId,
|
|
connectionVersion: reservation.version,
|
|
customerCode: input.customerCode,
|
|
});
|
|
const authorizationUrl = await buildKwpAuthorizationUrl({
|
|
state: challenge.payload.state,
|
|
nonce: challenge.payload.nonce,
|
|
codeChallenge: challenge.payload.codeChallenge,
|
|
customerCode: input.customerCode,
|
|
});
|
|
return { authorizationUrl, setCookie: challenge.cookie };
|
|
} catch {
|
|
await restorePendingKwpAttempt({ ...input, connectionId: reservation.connectionId }, reservation).catch(() => undefined);
|
|
throw new KwpConnectError("authorization_failed");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exchange the one-time authorization code and discover the user's allowed KWP
|
|
* mandates immediately while the five-minute access token is fresh. The OAuth
|
|
* material is persisted only as an inert, envelope-encrypted pending bundle;
|
|
* no connection becomes active before an explicit mandate binding is confirmed.
|
|
*/
|
|
export async function completeKwpOAuthConnection(input: {
|
|
readonly callbackParameters: { readonly code: string; readonly state: string; readonly iss?: string };
|
|
readonly challenge: KwpOAuthChallengePayload;
|
|
}): Promise<CompleteKwpOAuthConnectionResult> {
|
|
const { challenge } = input;
|
|
assertBoundIdentity(challenge.tenantId, challenge.userId);
|
|
|
|
const reservation = await loadPendingOAuthReservation({
|
|
tenantId: challenge.tenantId,
|
|
userId: challenge.userId,
|
|
connectionId: challenge.connectionId,
|
|
expectedVersion: parseConnectionVersion(challenge.connectionVersion),
|
|
});
|
|
if (isExpired(reservation)) {
|
|
await restorePendingKwpAttempt(challenge, reservation);
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
|
|
let runtimeConfig: ReturnType<typeof readKwpOidcRuntimeConfig>;
|
|
try {
|
|
runtimeConfig = readKwpOidcRuntimeConfig();
|
|
} catch {
|
|
await restorePendingKwpAttempt(challenge, reservation).catch(() => undefined);
|
|
throw new KwpConnectError("not_configured");
|
|
}
|
|
if (
|
|
runtimeConfig.hiveGridTenantId !== challenge.tenantId ||
|
|
runtimeConfig.customerCode !== challenge.customerCode ||
|
|
input.callbackParameters.state !== challenge.state ||
|
|
reservation.bundle.customerCode !== challenge.customerCode
|
|
) {
|
|
await restorePendingKwpAttempt(challenge, reservation).catch(() => undefined);
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
|
|
let exchanged: { readonly credentials: KwpCredentials; readonly tokenExpiresAt: Date };
|
|
try {
|
|
exchanged = await exchangeKwpAuthorizationCode(input.callbackParameters, {
|
|
state: challenge.state,
|
|
nonce: challenge.nonce,
|
|
codeVerifier: challenge.codeVerifier,
|
|
customerCode: challenge.customerCode,
|
|
});
|
|
} catch (error) {
|
|
await restorePendingKwpAttempt(challenge, reservation).catch(() => undefined);
|
|
throw new KwpConnectError("exchange_failed", {
|
|
diagnosticCode: error instanceof KwpOidcClientError ? (error.diagnosticCode ?? `oidc_${error.code.replaceAll("-", "_")}`) : "exchange_unexpected",
|
|
});
|
|
}
|
|
if (!Number.isFinite(exchanged.tokenExpiresAt.getTime())) {
|
|
await restorePendingKwpAttempt(challenge, reservation).catch(() => undefined);
|
|
throw new KwpConnectError("exchange_failed");
|
|
}
|
|
|
|
const oauth = toUnconfirmedCredentials(exchanged.credentials);
|
|
let discovery: KwpMandantSelectionBundle["discovery"];
|
|
try {
|
|
discovery = await discoverMandantsOrManualFallback(oauth);
|
|
} catch (error) {
|
|
await restorePendingKwpAttempt(challenge, reservation).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
const bundle: KwpMandantSelectionBundle = {
|
|
kind: KWP_MANDANT_SELECTION_KIND,
|
|
previous: reservation.bundle.previous,
|
|
oauth,
|
|
discovery,
|
|
issuedAt: new Date().toISOString(),
|
|
};
|
|
|
|
try {
|
|
const updated = await withTenantRLS(challenge.tenantId, async (tx) => {
|
|
const credentialsCiphertext = await encryptTenantField(challenge.tenantId, JSON.stringify(bundle), { client: tx as never });
|
|
return tx.dataConnection.updateMany({
|
|
where: {
|
|
id: challenge.connectionId,
|
|
tenantId: challenge.tenantId,
|
|
appId: KWP_APP_ID,
|
|
archivedAt: null,
|
|
connectionStatus: "pending",
|
|
createdByUserId: challenge.userId,
|
|
updatedAt: new Date(challenge.connectionVersion),
|
|
credentialsCiphertext: reservation.credentialsCiphertext,
|
|
tokenExpiresAt: reservation.tokenExpiresAt,
|
|
healthMessageKey: KWP_OAUTH_PENDING_HEALTH_KEY,
|
|
},
|
|
data: {
|
|
credentialsCiphertext,
|
|
tokenExpiresAt: exchanged.tokenExpiresAt,
|
|
connectionStatus: "pending",
|
|
healthMessageKey: KWP_MANDANT_SELECTION_HEALTH_KEY,
|
|
lastHealthCheck: null,
|
|
createdByUserId: challenge.userId,
|
|
},
|
|
});
|
|
});
|
|
if (updated.count !== 1) throw new KwpConnectError("invalid_state");
|
|
} catch (error) {
|
|
if (error instanceof KwpConnectError) throw error;
|
|
await restorePendingKwpAttempt(challenge, reservation).catch(() => undefined);
|
|
throw new KwpConnectError("persistence_failed");
|
|
}
|
|
|
|
await auditKwpConnection(challenge.tenantId, challenge.userId, "INTEGRATION_OAUTH_MANDANT_SELECTION_PENDING", challenge.connectionId, {
|
|
discovery: discovery.kind,
|
|
optionCount: discovery.kind === "available" ? discovery.options.length : 0,
|
|
});
|
|
|
|
return {
|
|
tenantId: challenge.tenantId,
|
|
tenantSlug: challenge.tenantSlug,
|
|
connectionId: challenge.connectionId,
|
|
};
|
|
}
|
|
|
|
/** Return only the safe selection projection; OAuth material never leaves this service. */
|
|
export async function loadKwpMandantSelection(input: {
|
|
readonly tenantId: string;
|
|
readonly userId: string;
|
|
readonly connectionId: string;
|
|
}): Promise<KwpMandantSelectionView> {
|
|
assertBoundIdentity(input.tenantId, input.userId);
|
|
const pending = await loadPendingMandantSelection(input);
|
|
if (isExpired(pending)) {
|
|
await restorePendingKwpAttempt(input, pending);
|
|
throw new KwpConnectError("selection_expired");
|
|
}
|
|
|
|
return {
|
|
connectionId: pending.connectionId,
|
|
customerCode: pending.bundle.oauth.customerCode,
|
|
version: pending.version,
|
|
mode: pending.bundle.discovery.kind,
|
|
options: pending.bundle.discovery.kind === "available" ? pending.bundle.discovery.options.map((option) => ({ ...option })) : [],
|
|
};
|
|
}
|
|
|
|
/** Validate one explicit binding, prove it against KWP, then atomically activate. */
|
|
export async function finalizeKwpOAuthConnection(input: {
|
|
readonly tenantId: string;
|
|
readonly userId: string;
|
|
readonly connectionId: string;
|
|
readonly version: string;
|
|
readonly mandantBinding: KwpMandantBinding;
|
|
}): Promise<{ readonly connectionId: string }> {
|
|
assertBoundIdentity(input.tenantId, input.userId);
|
|
const expectedVersion = parseConnectionVersion(input.version);
|
|
const pending = await loadPendingMandantSelection({
|
|
tenantId: input.tenantId,
|
|
userId: input.userId,
|
|
connectionId: input.connectionId,
|
|
expectedVersion,
|
|
});
|
|
|
|
if (isExpired(pending)) {
|
|
await restorePendingKwpAttempt(input, pending);
|
|
throw new KwpConnectError("selection_expired");
|
|
}
|
|
|
|
const mandantBinding = resolveConfirmedBinding(pending.bundle.discovery, input.mandantBinding);
|
|
const credentials: KwpCredentials = { ...pending.bundle.oauth, mandantBinding };
|
|
|
|
try {
|
|
await activateOAuthConnection({
|
|
tenantId: input.tenantId,
|
|
connectionId: input.connectionId,
|
|
appId: KWP_APP_ID,
|
|
credentials: credentials as unknown as Record<string, unknown>,
|
|
tokenExpiresAt: pending.tokenExpiresAt,
|
|
actorUserId: input.userId,
|
|
expectedUpdatedAt: expectedVersion,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof AppsEngineServiceError) {
|
|
if (error.messageKey === "integrations.kwp.health.tokenInvalid") {
|
|
await restorePendingKwpAttempt(input, pending);
|
|
throw new KwpConnectError("selection_expired");
|
|
}
|
|
throw new KwpConnectError("connection_failed", { diagnosticCode: healthDiagnosticCode(error.messageKey) });
|
|
}
|
|
if (error instanceof CatalogError && error.code === foundationErrors.notFound.code) {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
throw new KwpConnectError("persistence_failed");
|
|
}
|
|
|
|
await auditKwpConnection(input.tenantId, input.userId, "INTEGRATION_KWP_MANDANT_BIND", input.connectionId, {
|
|
binding: mandantBinding.kind,
|
|
source: pending.bundle.discovery.kind,
|
|
});
|
|
return { connectionId: input.connectionId };
|
|
}
|
|
|
|
/** Explicitly abandon one selection and restore the connection from before reconnect. */
|
|
export async function cancelKwpOAuthConnectionAttempt(input: {
|
|
readonly tenantId: string;
|
|
readonly userId: string;
|
|
readonly connectionId: string;
|
|
readonly version: string;
|
|
}): Promise<void> {
|
|
assertBoundIdentity(input.tenantId, input.userId);
|
|
const pending = await loadRestorablePendingAttempt({
|
|
...input,
|
|
expectedVersion: parseConnectionVersion(input.version),
|
|
});
|
|
await restorePendingKwpAttempt(input, pending);
|
|
await auditKwpConnection(input.tenantId, input.userId, "INTEGRATION_OAUTH_CANCEL", input.connectionId, {
|
|
restored: pending.previous !== null,
|
|
});
|
|
}
|
|
|
|
async function discoverMandantsOrManualFallback(credentials: KwpUnconfirmedCredentials): Promise<KwpMandantSelectionBundle["discovery"]> {
|
|
try {
|
|
const options = sanitizeMandantOptions(await discoverKwpMandants(credentials));
|
|
return { kind: "available", options };
|
|
} catch (error) {
|
|
if (error instanceof KwpConnectError && error.diagnosticCode === "discovery_invalid_response") {
|
|
return { kind: "manual" };
|
|
}
|
|
if (isManualDiscoveryFallback(error)) return { kind: "manual" };
|
|
throw new KwpConnectError("connection_failed", { diagnosticCode: discoveryDiagnosticCode(error) });
|
|
}
|
|
}
|
|
|
|
function isManualDiscoveryFallback(error: unknown): boolean {
|
|
if (!(error instanceof KwpError)) return false;
|
|
if (error.httpStatus === 401 || error.code === "auth_token_invalid" || error.code === "invalid_credentials" || error.code === "relay_host_invalid") {
|
|
return false;
|
|
}
|
|
return (
|
|
error.httpStatus === 403 ||
|
|
error.httpStatus === 404 ||
|
|
error.code === "auth_scope_missing" ||
|
|
error.code === "relay_not_found" ||
|
|
error.code === "mandant_discovery_unavailable" ||
|
|
error.code === "invalid_response" ||
|
|
error.code === "provider_outage" ||
|
|
error.code === "rate_limit_exceeded"
|
|
);
|
|
}
|
|
|
|
function discoveryDiagnosticCode(error: unknown): string {
|
|
return error instanceof KwpError ? `discovery_${error.code}` : "discovery_unexpected";
|
|
}
|
|
|
|
function sanitizeMandantOptions(value: unknown): readonly KwpMandantOption[] {
|
|
if (!Array.isArray(value) || value.length > MAX_DISCOVERED_MANDANTS) {
|
|
throw new KwpConnectError("connection_failed", { diagnosticCode: "discovery_invalid_response" });
|
|
}
|
|
const options: KwpMandantOption[] = [];
|
|
const seenCodes = new Set<string>();
|
|
for (const candidate of value) {
|
|
if (!isRecord(candidate)) throw new KwpConnectError("connection_failed", { diagnosticCode: "discovery_invalid_response" });
|
|
const { id, code, label } = candidate;
|
|
if (
|
|
typeof id !== "number" ||
|
|
!Number.isSafeInteger(id) ||
|
|
!isValidKwpContextCode(code) ||
|
|
(label !== undefined && typeof label !== "string") ||
|
|
(typeof label === "string" && (label.length > MAX_MANDANT_LABEL_LENGTH || label.trim() !== label)) ||
|
|
seenCodes.has(code)
|
|
) {
|
|
throw new KwpConnectError("connection_failed", { diagnosticCode: "discovery_invalid_response" });
|
|
}
|
|
seenCodes.add(code);
|
|
options.push({ id, code, label: typeof label === "string" && label ? label : code });
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function toUnconfirmedCredentials(credentials: KwpCredentials): KwpUnconfirmedCredentials {
|
|
return {
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
expiresAt: credentials.expiresAt,
|
|
issuer: credentials.issuer,
|
|
clientId: credentials.clientId,
|
|
clientSecret: credentials.clientSecret,
|
|
customerCode: credentials.customerCode,
|
|
scopes: [...credentials.scopes],
|
|
};
|
|
}
|
|
|
|
async function loadPendingMandantSelection(input: {
|
|
readonly tenantId: string;
|
|
readonly userId: string;
|
|
readonly connectionId: string;
|
|
readonly expectedVersion?: Date;
|
|
}): Promise<PendingKwpMandantSelection> {
|
|
try {
|
|
return await withTenantRLS(input.tenantId, async (tx) => {
|
|
const row = await findPendingAttemptRow(tx, input);
|
|
const plaintext = await decryptTenantField(input.tenantId, row.credentialsCiphertext, { client: tx as never });
|
|
const bundle = parseMandantSelectionBundle(plaintext);
|
|
if (
|
|
row.healthMessageKey !== KWP_MANDANT_SELECTION_HEALTH_KEY ||
|
|
row.tokenExpiresAt.toISOString() !== bundle.oauth.expiresAt
|
|
) {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
return {
|
|
connectionId: row.id,
|
|
version: row.updatedAt.toISOString(),
|
|
credentialsCiphertext: row.credentialsCiphertext,
|
|
tokenExpiresAt: row.tokenExpiresAt,
|
|
bundle,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof KwpConnectError) throw error;
|
|
throw new KwpConnectError("persistence_failed");
|
|
}
|
|
}
|
|
|
|
async function loadPendingOAuthReservation(input: {
|
|
readonly tenantId: string;
|
|
readonly userId: string;
|
|
readonly connectionId: string;
|
|
readonly expectedVersion?: Date;
|
|
}): Promise<PendingKwpOAuthReservation> {
|
|
try {
|
|
return await withTenantRLS(input.tenantId, async (tx) => {
|
|
const row = await findPendingAttemptRow(tx, input);
|
|
const plaintext = await decryptTenantField(input.tenantId, row.credentialsCiphertext, { client: tx as never });
|
|
const bundle = parseKwpOAuthReservationBundle(plaintext);
|
|
if (
|
|
!bundle ||
|
|
row.healthMessageKey !== KWP_OAUTH_PENDING_HEALTH_KEY ||
|
|
row.tokenExpiresAt.toISOString() !== bundle.expiresAt
|
|
) {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
return {
|
|
connectionId: row.id,
|
|
version: row.updatedAt.toISOString(),
|
|
credentialsCiphertext: row.credentialsCiphertext,
|
|
tokenExpiresAt: row.tokenExpiresAt,
|
|
bundle,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof KwpConnectError) throw error;
|
|
throw new KwpConnectError("persistence_failed");
|
|
}
|
|
}
|
|
|
|
async function loadRestorablePendingAttempt(input: {
|
|
readonly tenantId: string;
|
|
readonly userId: string;
|
|
readonly connectionId: string;
|
|
readonly expectedVersion?: Date;
|
|
}): Promise<RestorablePendingKwpAttempt> {
|
|
try {
|
|
return await withTenantRLS(input.tenantId, async (tx) => {
|
|
const row = await findPendingAttemptRow(tx, input);
|
|
const plaintext = await decryptTenantField(input.tenantId, row.credentialsCiphertext, { client: tx as never });
|
|
const lifecycle = parseKwpPendingAttemptLifecycle(plaintext);
|
|
const expectedHealthKey = lifecycle?.phase === "oauth" ? KWP_OAUTH_PENDING_HEALTH_KEY : KWP_MANDANT_SELECTION_HEALTH_KEY;
|
|
if (!lifecycle || row.healthMessageKey !== expectedHealthKey) throw new KwpConnectError("invalid_state");
|
|
return {
|
|
connectionId: row.id,
|
|
version: row.updatedAt.toISOString(),
|
|
credentialsCiphertext: row.credentialsCiphertext,
|
|
tokenExpiresAt: row.tokenExpiresAt,
|
|
healthMessageKey: expectedHealthKey,
|
|
previous: lifecycle.previous,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof KwpConnectError) throw error;
|
|
throw new KwpConnectError("persistence_failed");
|
|
}
|
|
}
|
|
|
|
async function findPendingAttemptRow(
|
|
tx: TenantRlsClient,
|
|
input: {
|
|
readonly tenantId: string;
|
|
readonly userId: string;
|
|
readonly connectionId: string;
|
|
readonly expectedVersion?: Date;
|
|
}
|
|
) {
|
|
const row = await tx.dataConnection.findFirst({
|
|
where: {
|
|
id: input.connectionId,
|
|
tenantId: input.tenantId,
|
|
appId: KWP_APP_ID,
|
|
archivedAt: null,
|
|
connectionStatus: "pending",
|
|
createdByUserId: input.userId,
|
|
...(input.expectedVersion ? { updatedAt: input.expectedVersion } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
updatedAt: true,
|
|
credentialsCiphertext: true,
|
|
tokenExpiresAt: true,
|
|
healthMessageKey: true,
|
|
},
|
|
});
|
|
if (!row?.credentialsCiphertext || !row.tokenExpiresAt) throw new KwpConnectError("invalid_state");
|
|
return {
|
|
...row,
|
|
credentialsCiphertext: row.credentialsCiphertext,
|
|
tokenExpiresAt: row.tokenExpiresAt,
|
|
};
|
|
}
|
|
|
|
async function restorePendingKwpAttempt(
|
|
input: { readonly tenantId: string; readonly userId: string; readonly connectionId: string },
|
|
pending: PendingKwpMandantSelection | PendingKwpOAuthReservation | RestorablePendingKwpAttempt
|
|
): Promise<void> {
|
|
const previous = "bundle" in pending ? pending.bundle.previous : pending.previous;
|
|
const healthMessageKey =
|
|
"healthMessageKey" in pending
|
|
? pending.healthMessageKey
|
|
: pending.bundle.kind === KWP_OAUTH_RESERVATION_KIND
|
|
? KWP_OAUTH_PENDING_HEALTH_KEY
|
|
: KWP_MANDANT_SELECTION_HEALTH_KEY;
|
|
try {
|
|
const restored = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.dataConnection.updateMany({
|
|
where: {
|
|
id: input.connectionId,
|
|
tenantId: input.tenantId,
|
|
appId: KWP_APP_ID,
|
|
archivedAt: null,
|
|
connectionStatus: "pending",
|
|
createdByUserId: input.userId,
|
|
updatedAt: new Date(pending.version),
|
|
credentialsCiphertext: pending.credentialsCiphertext,
|
|
tokenExpiresAt: pending.tokenExpiresAt,
|
|
healthMessageKey,
|
|
},
|
|
data: {
|
|
...restorationData(previous, input.userId),
|
|
// Always invalidate the browser's old callback/selection fence.
|
|
updatedAt: new Date(Math.max(Date.now(), new Date(pending.version).getTime() + 1)),
|
|
},
|
|
})
|
|
);
|
|
if (restored.count !== 1) throw new KwpConnectError("invalid_state");
|
|
} catch (error) {
|
|
if (error instanceof KwpConnectError) throw error;
|
|
throw new KwpConnectError("persistence_failed");
|
|
}
|
|
}
|
|
|
|
function parseMandantSelectionBundle(plaintext: string): KwpMandantSelectionBundle {
|
|
let value: unknown;
|
|
try {
|
|
value = JSON.parse(plaintext);
|
|
} catch {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
if (
|
|
!isRecord(value) ||
|
|
(value.kind !== KWP_MANDANT_SELECTION_KIND && value.kind !== KWP_LEGACY_MANDANT_SELECTION_KIND) ||
|
|
!isRecord(value.oauth) ||
|
|
!isRecord(value.discovery)
|
|
) {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
const lifecycle = parseKwpPendingAttemptLifecycle(plaintext);
|
|
if (!lifecycle || lifecycle.phase !== "mandant") throw new KwpConnectError("invalid_state");
|
|
const oauth = value.oauth;
|
|
if (
|
|
typeof oauth.accessToken !== "string" ||
|
|
!oauth.accessToken ||
|
|
typeof oauth.refreshToken !== "string" ||
|
|
!oauth.refreshToken ||
|
|
typeof oauth.expiresAt !== "string" ||
|
|
!Number.isFinite(Date.parse(oauth.expiresAt)) ||
|
|
typeof oauth.issuer !== "string" ||
|
|
!oauth.issuer ||
|
|
typeof oauth.clientId !== "string" ||
|
|
!oauth.clientId ||
|
|
typeof oauth.clientSecret !== "string" ||
|
|
!oauth.clientSecret ||
|
|
!isValidKwpContextCode(oauth.customerCode) ||
|
|
!Array.isArray(oauth.scopes) ||
|
|
!oauth.scopes.every((scope) => typeof scope === "string" && scope.length > 0) ||
|
|
oauth.mandantBinding !== undefined ||
|
|
typeof value.issuedAt !== "string" ||
|
|
!Number.isFinite(Date.parse(value.issuedAt))
|
|
) {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
|
|
let discovery: KwpMandantSelectionBundle["discovery"];
|
|
if (value.discovery.kind === "manual") {
|
|
discovery = { kind: "manual" };
|
|
} else if (value.discovery.kind === "available") {
|
|
try {
|
|
discovery = { kind: "available", options: sanitizeMandantOptions(value.discovery.options) };
|
|
} catch {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
} else {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
|
|
return {
|
|
kind: KWP_MANDANT_SELECTION_KIND,
|
|
previous: lifecycle.previous,
|
|
oauth: {
|
|
accessToken: oauth.accessToken,
|
|
refreshToken: oauth.refreshToken,
|
|
expiresAt: oauth.expiresAt,
|
|
issuer: oauth.issuer,
|
|
clientId: oauth.clientId,
|
|
clientSecret: oauth.clientSecret,
|
|
customerCode: oauth.customerCode,
|
|
scopes: [...oauth.scopes],
|
|
},
|
|
discovery,
|
|
issuedAt: value.issuedAt,
|
|
};
|
|
}
|
|
|
|
function resolveConfirmedBinding(discovery: KwpMandantSelectionBundle["discovery"], requested: KwpMandantBinding): KwpMandantBinding {
|
|
if (discovery.kind === "available" && discovery.options.length > 0) {
|
|
if (requested.kind !== "selected") throw new KwpConnectError("invalid_selection");
|
|
const option = discovery.options.find((candidate) => candidate.code === requested.code);
|
|
if (!option) throw new KwpConnectError("invalid_selection");
|
|
return { kind: "selected", code: option.code, label: option.label };
|
|
}
|
|
|
|
if (requested.kind === "none") return { kind: "none" };
|
|
if (requested.kind !== "selected" || !isValidKwpContextCode(requested.code) || requested.code.trim() !== requested.code) {
|
|
throw new KwpConnectError("invalid_selection");
|
|
}
|
|
return { kind: "selected", code: requested.code };
|
|
}
|
|
|
|
function restorationData(previous: KwpRestorableConnectionSnapshot | null, actorUserId: string) {
|
|
if (!previous) {
|
|
return {
|
|
credentialsCiphertext: null,
|
|
tokenExpiresAt: null,
|
|
connectionStatus: "pending" as const,
|
|
healthMessageKey: KWP_NOT_CONNECTED_HEALTH_KEY,
|
|
lastHealthCheck: new Date(),
|
|
createdByUserId: actorUserId,
|
|
};
|
|
}
|
|
return {
|
|
credentialsCiphertext: previous.credentialsCiphertext,
|
|
tokenExpiresAt: previous.tokenExpiresAt ? new Date(previous.tokenExpiresAt) : null,
|
|
connectionStatus: previous.connectionStatus,
|
|
healthMessageKey: previous.healthMessageKey,
|
|
lastHealthCheck: previous.lastHealthCheck ? new Date(previous.lastHealthCheck) : null,
|
|
createdByUserId: previous.createdByUserId,
|
|
};
|
|
}
|
|
|
|
function isExpired(pending: { readonly tokenExpiresAt: Date }): boolean {
|
|
return !Number.isFinite(pending.tokenExpiresAt.getTime()) || pending.tokenExpiresAt.getTime() <= Date.now();
|
|
}
|
|
|
|
function parseConnectionVersion(value: string): Date {
|
|
const timestamp = Date.parse(value);
|
|
if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== value) throw new KwpConnectError("invalid_state");
|
|
return new Date(timestamp);
|
|
}
|
|
|
|
function healthDiagnosticCode(messageKey: string): string {
|
|
switch (messageKey) {
|
|
case "integrations.kwp.health.tokenInvalid":
|
|
return "health_token_invalid";
|
|
case "integrations.kwp.health.scopeMissing":
|
|
return "health_scope_missing";
|
|
case "integrations.kwp.health.rateLimited":
|
|
return "health_rate_limited";
|
|
case "integrations.kwp.health.providerOutage":
|
|
return "health_provider_outage";
|
|
case "integrations.kwp.health.relayInvalid":
|
|
return "health_relay_invalid";
|
|
case "integrations.kwp.health.credentialsInvalid":
|
|
return "health_credentials_invalid";
|
|
case "integrations.kwp.health.mandantUnconfirmed":
|
|
return "health_mandant_unconfirmed";
|
|
default:
|
|
return "health_unknown";
|
|
}
|
|
}
|
|
|
|
async function ensureKwpConnectionSlot(
|
|
tenantId: string,
|
|
actorUserId: string,
|
|
customerCode: string
|
|
): Promise<PendingKwpOAuthReservation> {
|
|
let created = false;
|
|
const issuedAt = new Date();
|
|
const expiresAt = new Date(issuedAt.getTime() + KWP_OAUTH_CHALLENGE_TTL_MS);
|
|
const connection = await withTenantRLS(tenantId, async (tx) => {
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`kwp-oauth-slot:${tenantId}`}, ${KWP_SLOT_LOCK_SALT}))`;
|
|
const candidates = await tx.dataConnection.findMany({
|
|
where: { tenantId, appId: KWP_APP_ID, archivedAt: null },
|
|
select: {
|
|
id: true,
|
|
updatedAt: true,
|
|
connectionStatus: true,
|
|
credentialsCiphertext: true,
|
|
tokenExpiresAt: true,
|
|
healthMessageKey: true,
|
|
lastHealthCheck: true,
|
|
createdByUserId: true,
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
take: 2,
|
|
});
|
|
if (candidates.length > 1) throw new KwpConnectError("slot_conflict");
|
|
if (candidates[0]) {
|
|
const candidate = candidates[0];
|
|
if (candidate.healthMessageKey === "integrations.health.refreshInProgress") {
|
|
throw new KwpConnectError("slot_conflict");
|
|
}
|
|
const previous = await resolvePreviousConnectionSnapshot(tenantId, tx, candidate);
|
|
const bundle: KwpOAuthReservationBundle = {
|
|
kind: KWP_OAUTH_RESERVATION_KIND,
|
|
customerCode,
|
|
previous,
|
|
issuedAt: issuedAt.toISOString(),
|
|
expiresAt: expiresAt.toISOString(),
|
|
};
|
|
const credentialsCiphertext = await encryptTenantField(tenantId, JSON.stringify(bundle), { client: tx as never });
|
|
const attemptVersion = new Date(Math.max(Date.now(), candidate.updatedAt.getTime() + 1));
|
|
const invalidated = await tx.dataConnection.updateMany({
|
|
where: {
|
|
id: candidate.id,
|
|
tenantId,
|
|
appId: KWP_APP_ID,
|
|
archivedAt: null,
|
|
connectionStatus: candidate.connectionStatus,
|
|
updatedAt: candidate.updatedAt,
|
|
credentialsCiphertext: candidate.credentialsCiphertext,
|
|
tokenExpiresAt: candidate.tokenExpiresAt,
|
|
healthMessageKey: candidate.healthMessageKey,
|
|
lastHealthCheck: candidate.lastHealthCheck,
|
|
},
|
|
data: {
|
|
credentialsCiphertext,
|
|
tokenExpiresAt: expiresAt,
|
|
connectionStatus: "pending",
|
|
healthMessageKey: KWP_OAUTH_PENDING_HEALTH_KEY,
|
|
lastHealthCheck: null,
|
|
createdByUserId: actorUserId,
|
|
updatedAt: attemptVersion,
|
|
},
|
|
});
|
|
if (invalidated.count !== 1) throw new KwpConnectError("slot_conflict");
|
|
return {
|
|
connectionId: candidate.id,
|
|
version: attemptVersion.toISOString(),
|
|
credentialsCiphertext,
|
|
tokenExpiresAt: expiresAt,
|
|
bundle,
|
|
};
|
|
}
|
|
|
|
const bundle: KwpOAuthReservationBundle = {
|
|
kind: KWP_OAUTH_RESERVATION_KIND,
|
|
customerCode,
|
|
previous: null,
|
|
issuedAt: issuedAt.toISOString(),
|
|
expiresAt: expiresAt.toISOString(),
|
|
};
|
|
const credentialsCiphertext = await encryptTenantField(tenantId, JSON.stringify(bundle), { client: tx as never });
|
|
const connection = await tx.dataConnection.create({
|
|
data: {
|
|
tenantId,
|
|
appId: KWP_APP_ID,
|
|
label: KWP_CONNECTION_LABEL,
|
|
credentialsCiphertext,
|
|
connectionStatus: "pending",
|
|
healthMessageKey: KWP_OAUTH_PENDING_HEALTH_KEY,
|
|
tokenExpiresAt: expiresAt,
|
|
createdByUserId: actorUserId,
|
|
},
|
|
select: { id: true, updatedAt: true },
|
|
});
|
|
created = true;
|
|
return {
|
|
connectionId: connection.id,
|
|
version: connection.updatedAt.toISOString(),
|
|
credentialsCiphertext,
|
|
tokenExpiresAt: expiresAt,
|
|
bundle,
|
|
};
|
|
});
|
|
|
|
await auditKwpConnection(tenantId, actorUserId, "INTEGRATION_OAUTH_RESERVE", connection.connectionId, { created });
|
|
return connection;
|
|
}
|
|
|
|
async function resolvePreviousConnectionSnapshot(
|
|
tenantId: string,
|
|
tx: TenantRlsClient,
|
|
candidate: {
|
|
readonly connectionStatus: string;
|
|
readonly credentialsCiphertext: string | null;
|
|
readonly tokenExpiresAt: Date | null;
|
|
readonly healthMessageKey: string | null;
|
|
readonly lastHealthCheck: Date | null;
|
|
readonly createdByUserId: string | null;
|
|
}
|
|
): Promise<KwpRestorableConnectionSnapshot | null> {
|
|
if (candidate.connectionStatus === "pending") {
|
|
if (!candidate.credentialsCiphertext) return null;
|
|
const plaintext = await decryptTenantField(tenantId, candidate.credentialsCiphertext, { client: tx as never });
|
|
const lifecycle = parseKwpPendingAttemptLifecycle(plaintext);
|
|
if (!lifecycle) throw new KwpConnectError("slot_conflict");
|
|
return lifecycle.previous;
|
|
}
|
|
if (!isRestorableConnectionStatus(candidate.connectionStatus)) throw new KwpConnectError("slot_conflict");
|
|
return {
|
|
credentialsCiphertext: candidate.credentialsCiphertext,
|
|
connectionStatus: candidate.connectionStatus,
|
|
tokenExpiresAt: candidate.tokenExpiresAt?.toISOString() ?? null,
|
|
healthMessageKey: candidate.healthMessageKey,
|
|
lastHealthCheck: candidate.lastHealthCheck?.toISOString() ?? null,
|
|
createdByUserId: candidate.createdByUserId,
|
|
};
|
|
}
|
|
|
|
function isRestorableConnectionStatus(value: string): value is KwpRestorableConnectionStatus {
|
|
return value === "active" || value === "degraded" || value === "broken" || value === "revoked";
|
|
}
|
|
|
|
async function auditKwpConnection(tenantId: string, actorId: string, action: string, targetId: string, metadata: Record<string, unknown>): Promise<void> {
|
|
await createAuditEvent({
|
|
tenantId,
|
|
actorId,
|
|
actorType: "user",
|
|
action,
|
|
targetType: "DataConnection",
|
|
targetId,
|
|
sourceModule: "integrations",
|
|
outcome: "success",
|
|
metadata: { appId: KWP_APP_ID, ...metadata },
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
function assertBoundIdentity(tenantId: string, userId: string): void {
|
|
const context = getRlsExecutionContext();
|
|
if (context?.tenantId !== tenantId || context.userId !== userId) {
|
|
throw new KwpConnectError("invalid_state");
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|