@codemation/host 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +483 -0
- package/dist/{ApiPaths-CLTHphYZ.js → ApiPaths-Dv1dcHu_.js} +4 -4
- package/dist/ApiPaths-Dv1dcHu_.js.map +1 -0
- package/dist/{AppConfigFactory-YnveXE9k.d.ts → AppConfigFactory-BT0y0LVC.d.ts} +8490 -5548
- package/dist/{AppConfigFactory-C6q-CSKb.js → AppConfigFactory-Cx4qQvRk.js} +112 -52
- package/dist/AppConfigFactory-Cx4qQvRk.js.map +1 -0
- package/dist/{AppContainerFactory-qaqc-R1D.js → AppContainerFactory-DRTjG7nG.js} +7298 -1732
- package/dist/AppContainerFactory-DRTjG7nG.js.map +1 -0
- package/dist/{CodemationAppContext-DRu1Dpri.d.ts → CodemationAppContext-CGFYVcSb.d.ts} +14 -4
- package/dist/{CodemationAuthoring.types-DZl-sJaM.js → CodemationAuthoring.types-BteaR3Dc.js} +19 -6
- package/dist/CodemationAuthoring.types-BteaR3Dc.js.map +1 -0
- package/dist/{CodemationAuthoring.types-fBRppnmi.d.ts → CodemationAuthoring.types-DiKKogum.d.ts} +30 -5
- package/dist/{CodemationConfigNormalizer-DVko3cVN.d.ts → CodemationConfigNormalizer-48f-T66P.d.ts} +3 -3
- package/dist/{CodemationConsumerConfigLoader-BeAUS144.js → CodemationConsumerConfigLoader-By-6tuGc.js} +81 -10
- package/dist/CodemationConsumerConfigLoader-By-6tuGc.js.map +1 -0
- package/dist/{CodemationConsumerConfigLoader-DJWr86f-.d.ts → CodemationConsumerConfigLoader-_PIYqwVx.d.ts} +18 -2
- package/dist/{CodemationPluginListMerger-B-W5Fa_X.js → CodemationPluginListMerger-D1B1IEbt.js} +1 -1
- package/dist/{CodemationPluginListMerger-B-W5Fa_X.js.map → CodemationPluginListMerger-D1B1IEbt.js.map} +1 -1
- package/dist/{CodemationPluginListMerger-DGc-jfa2.d.ts → CodemationPluginListMerger-DP7djJ9S.d.ts} +151 -19
- package/dist/CodemationTsyringeTypeInfoRegistrar-Bj6FJYFz.js +97 -0
- package/dist/CodemationTsyringeTypeInfoRegistrar-Bj6FJYFz.js.map +1 -0
- package/dist/{CodemationWhitelabelConfig-CWbcyQqn.d.ts → CodemationWhitelabelConfig-Ca2mCUeC.d.ts} +2 -2
- package/dist/{CollectionContracts.types-DdpHft0i.d.ts → CollectionContracts.types-DDyFYT_D.d.ts} +1 -1
- package/dist/{CredentialContractsRegistry-DrMIDSw8.d.ts → CredentialContractsRegistry-Bq2bq28t.d.ts} +2 -2
- package/dist/{CredentialServices-UfvHB-rN.d.ts → CredentialServices-BLloBztI.d.ts} +65 -20
- package/dist/{CredentialServices-CgxwguAv.js → CredentialServices-Dk8yypeL.js} +310 -51
- package/dist/CredentialServices-Dk8yypeL.js.map +1 -0
- package/dist/InternalHonoApiRouteRegistrar-c7t3KnV_.d.ts +17 -0
- package/dist/InternalPingRegistrar-DY3kSfxP.js +221 -0
- package/dist/InternalPingRegistrar-DY3kSfxP.js.map +1 -0
- package/dist/{ItemsInputNormalizer-C-KHg9Mo.d.ts → ItemsInputNormalizer-_RwIfRIQ.d.ts} +89 -25
- package/dist/{LogLevelPolicyFactory-CampWO0l.d.ts → LogLevelPolicyFactory-ewCHLDLn.d.ts} +2 -2
- package/dist/{PublicFrontendBootstrap-DzBgwOnG.d.ts → PublicFrontendBootstrap-Cev3qK46.d.ts} +9 -2
- package/dist/PublicFrontendBootstrapFactory-Dv04tJ-6.d.ts +82 -0
- package/dist/{PublicFrontendBootstrapJsonCodec-Cl_DLRh0.d.ts → PublicFrontendBootstrapJsonCodec-CXG9Dxft.d.ts} +3 -3
- package/dist/{PublicFrontendBootstrapJsonCodec-DzqvA0uo.js → PublicFrontendBootstrapJsonCodec-CegIF_ne.js} +7 -2
- package/dist/PublicFrontendBootstrapJsonCodec-CegIF_ne.js.map +1 -0
- package/dist/ServerLoggerFactory-Ckk52S3w.js +223 -0
- package/dist/ServerLoggerFactory-Ckk52S3w.js.map +1 -0
- package/dist/{TelemetryContracts-DbaNomrH.d.ts → TelemetryContracts-BtDx84Cp.d.ts} +13 -4
- package/dist/{WorkflowPolicyUiPresentationFactory-DQEY-h_S.d.ts → WorkflowPolicyUiPresentationFactory-6MyjCvBO.d.ts} +2 -2
- package/dist/{WorkflowPolicyUiPresentationFactory-DhPqQ9aB.js → WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js} +1 -1
- package/dist/{WorkflowPolicyUiPresentationFactory-DhPqQ9aB.js.map → WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js.map} +1 -1
- package/dist/{WorkflowViewContracts-CzK2KFuz.d.ts → WorkflowViewContracts-B7aFQcIw.d.ts} +10 -1
- package/dist/authoring.d.ts +5 -5
- package/dist/authoring.js +1 -1
- package/dist/client.d.ts +4 -4
- package/dist/client.js +2 -2
- package/dist/consumer.d.ts +6 -6
- package/dist/consumer.js +2 -2
- package/dist/credentials.d.ts +6 -6
- package/dist/credentials.js +1 -1
- package/dist/devServerSidecar.d.ts +2 -2
- package/dist/devServerSidecar.js +1 -94
- package/dist/devServerSidecar.js.map +1 -1
- package/dist/dto.d.ts +6 -6
- package/dist/{index-BbBk26m0.d.ts → index-DilAYwnH.d.ts} +49 -3
- package/dist/index.d.ts +141 -21
- package/dist/index.js +109 -14
- package/dist/index.js.map +1 -0
- package/dist/mapping.d.ts +2 -2
- package/dist/mapping.js +1 -1
- package/dist/nextServer.d.ts +42 -113
- package/dist/nextServer.js +9 -7
- package/dist/pairing.d.ts +93 -0
- package/dist/pairing.js +5 -0
- package/dist/pairing.types-snfZ_OzB.d.ts +19 -0
- package/dist/persistenceServer-B71RGvSj.d.ts +30 -0
- package/dist/{persistenceServer-CmsIKnO9.js → persistenceServer-C-hH4z6l.js} +2 -2
- package/dist/{persistenceServer-CmsIKnO9.js.map → persistenceServer-C-hH4z6l.js.map} +1 -1
- package/dist/persistenceServer.d.ts +8 -8
- package/dist/persistenceServer.js +3 -3
- package/dist/{server-MUNGsBYK.d.ts → server-09PKasWR.d.ts} +21 -6
- package/dist/{server-CJFfY67o.js → server-vtRCPgRJ.js} +7 -6
- package/dist/{server-CJFfY67o.js.map → server-vtRCPgRJ.js.map} +1 -1
- package/dist/server.d.ts +14 -14
- package/dist/server.js +13 -11
- package/package.json +47 -58
- package/prisma/migrations/20260519000000_workflow_audit_log/migration.sql +23 -0
- package/prisma/migrations/20260519100000_storage_growth_fixes/migration.sql +61 -0
- package/prisma/migrations.sqlite/20260519000000_workflow_audit_log/migration.sql +21 -0
- package/prisma/migrations.sqlite/20260519100000_storage_growth_fixes/migration.sql +29 -0
- package/prisma/schema.postgresql.prisma +55 -17
- package/prisma/schema.sqlite.prisma +55 -17
- package/prisma-generated/prisma-postgresql-client/edge.js +33 -5
- package/prisma-generated/prisma-postgresql-client/index-browser.js +29 -1
- package/prisma-generated/prisma-postgresql-client/index.d.ts +8933 -5716
- package/prisma-generated/prisma-postgresql-client/index.js +33 -5
- package/prisma-generated/prisma-postgresql-client/package.json +1 -1
- package/prisma-generated/prisma-postgresql-client/schema.prisma +38 -0
- package/prisma-generated/prisma-sqlite-client/edge.js +33 -5
- package/prisma-generated/prisma-sqlite-client/index-browser.js +29 -1
- package/prisma-generated/prisma-sqlite-client/index.d.ts +8925 -5713
- package/prisma-generated/prisma-sqlite-client/index.js +33 -5
- package/prisma-generated/prisma-sqlite-client/package.json +1 -1
- package/prisma-generated/prisma-sqlite-client/schema.prisma +38 -0
- package/scripts/check-collections.mjs +18 -0
- package/scripts/generate-prisma-clients.mjs +20 -11
- package/src/application/WorkflowAuditLogPruneScheduler.ts +96 -0
- package/src/application/auth/AuthenticatedPrincipal.ts +4 -0
- package/src/application/commands/StartWorkflowRunCommandHandler.ts +4 -0
- package/src/application/contracts/WorkflowViewContracts.ts +6 -0
- package/src/application/contracts/WorkflowWebsocketMessage.ts +3 -1
- package/src/application/mapping/WorkflowDefinitionMapper.ts +40 -1
- package/src/application/runs/WorkflowRunRetentionPruneScheduler.ts +7 -1
- package/src/application/telemetry/OtelExecutionTelemetry.types.ts +5 -0
- package/src/application/telemetry/OtelExecutionTelemetryFactory.ts +4 -0
- package/src/application/telemetry/StoredTelemetrySpanScope.ts +6 -2
- package/src/application/telemetry/TelemetryRetentionTimestampFactory.ts +27 -17
- package/src/application/telemetry/TelemetrySpanPublisher.ts +11 -0
- package/src/application/websocket/TelemetrySpanWebsocketRelay.ts +31 -0
- package/src/applicationTokens.ts +20 -1
- package/src/audit/IAuditEmitter.ts +32 -0
- package/src/audit/PrismaWorkflowAuditLogRepository.ts +34 -0
- package/src/audit/WorkflowAuditLogWriter.ts +125 -0
- package/src/auth/managed/ManagedAuthConfig.ts +29 -0
- package/src/auth/managed/ManagedAuthMiddleware.ts +52 -0
- package/src/auth/managed/ManagedCorsMiddleware.ts +43 -0
- package/src/auth/managed/ManagedModeBootGuard.ts +27 -0
- package/src/auth/managed/index.ts +5 -0
- package/src/bootstrap/AppContainerFactory.ts +295 -29
- package/src/bootstrap/AppContainerLifecycle.ts +31 -0
- package/src/bootstrap/perf/BootTimer.ts +168 -0
- package/src/bootstrap/runtime/AppConfigFactory.ts +21 -65
- package/src/bootstrap/runtime/FrontendRuntime.ts +4 -1
- package/src/bootstrap/runtime/HeadlessApiRuntime.ts +47 -0
- package/src/bootstrap/runtime/WorkerRuntime.ts +2 -1
- package/src/credentials/BrokerClient.ts +49 -0
- package/src/credentials/BrokerRefreshError.ts +12 -0
- package/src/credentials/BrokerRefreshInvalidGrantError.ts +13 -0
- package/src/credentials/ControlPlaneCatalogFetcher.ts +261 -0
- package/src/credentials/CredentialOAuth2MaterialReader.ts +136 -0
- package/src/credentials/InternalCredentialsListRegistrar.ts +48 -0
- package/src/credentials/InternalCredentialsPushRegistrar.ts +125 -0
- package/src/credentials/LocalOAuthFlowExecutor.ts +316 -0
- package/src/credentials/ManagedOAuthFlowExecutor.ts +94 -0
- package/src/credentials/ManagedOAuthRefreshInvalidGrantError.ts +13 -0
- package/src/credentials/catalogTypes.ts +4 -0
- package/src/credentials/refresh/CredentialDisconnectedError.ts +11 -0
- package/src/domain/credentials/CredentialBindingService.ts +54 -2
- package/src/domain/credentials/CredentialKeyRotatedError.ts +22 -0
- package/src/domain/credentials/CredentialSecretCipher.ts +68 -6
- package/src/domain/credentials/CredentialTypeRegistryImpl.ts +117 -10
- package/src/domain/credentials/OAuth2RedirectUriResolver.ts +79 -0
- package/src/domain/credentials/WorkflowCredentialNodeResolver.ts +14 -5
- package/src/domain/telemetry/TelemetryContracts.ts +7 -1
- package/src/domain/workflows/WorkflowActivationPreflight.ts +24 -1
- package/src/domain/workflows/WorkflowActivationPreflightRules.ts +40 -1
- package/src/index.ts +9 -0
- package/src/infrastructure/binary/LocalFilesystemBinaryStorageRegistry.ts +29 -1
- package/src/infrastructure/binary/S3BinaryStorage.ts +169 -0
- package/src/infrastructure/binary/S3BinaryStorageConfig.ts +17 -0
- package/src/infrastructure/config/CodemationPluginRegistrar.ts +3 -1
- package/src/infrastructure/persistence/CodemationDatabaseUrlParser.ts +41 -0
- package/src/infrastructure/persistence/InMemoryTelemetryArtifactStore.ts +8 -3
- package/src/infrastructure/persistence/PrismaMigrationDeployer.ts +21 -13
- package/src/infrastructure/persistence/PrismaTelemetryArtifactStore.ts +43 -8
- package/src/infrastructure/persistence/PrismaWorkflowRunRepository.ts +26 -3
- package/src/infrastructure/persistence/PrismaWorkflowSnapshotRepository.ts +48 -0
- package/src/mcp/AgentMcpIntegrationImpl.ts +344 -0
- package/src/mcp/McpClientFactory.ts +29 -0
- package/src/mcp/McpConnectionPool.ts +184 -0
- package/src/mcp/McpConnectionPool.types.ts +12 -0
- package/src/mcp/McpServerCatalog.ts +104 -0
- package/src/mcp/index.ts +5 -0
- package/src/pairing/HmacRequestSigner.ts +32 -0
- package/src/pairing/IncomingHmacVerifier.ts +82 -0
- package/src/pairing/InternalHmacAuthMiddleware.ts +33 -0
- package/src/pairing/InternalPingRegistrar.ts +25 -0
- package/src/pairing/PairedFetch.ts +33 -0
- package/src/pairing/PairingConfigFactory.ts +35 -0
- package/src/pairing/PairingConfigToken.ts +6 -0
- package/src/pairing/index.ts +14 -0
- package/src/pairing/pairing.types.ts +18 -0
- package/src/pairing.ts +17 -0
- package/src/persistenceServer.ts +1 -0
- package/src/presentation/config/AppConfig.ts +7 -1
- package/src/presentation/config/CodemationAuthConfig.ts +1 -1
- package/src/presentation/config/CodemationAuthoring.types.ts +60 -5
- package/src/presentation/config/CodemationConfig.ts +9 -0
- package/src/presentation/config/CodemationConfigNormalizer.ts +39 -1
- package/src/presentation/config/CodemationPlugin.ts +2 -1
- package/src/presentation/frontend/CodemationFrontendAuthSnapshot.ts +5 -0
- package/src/presentation/frontend/CodemationFrontendAuthSnapshotFactory.ts +7 -1
- package/src/presentation/frontend/PublicFrontendBootstrap.ts +2 -0
- package/src/presentation/frontend/PublicFrontendBootstrapFactory.ts +5 -1
- package/src/presentation/frontend/PublicFrontendBootstrapJsonCodec.ts +4 -1
- package/src/presentation/http/ApiPaths.ts +4 -4
- package/src/presentation/http/HeadlessHttpServerFactory.ts +56 -0
- package/src/presentation/http/ServerHttpErrorResponseFactory.ts +39 -2
- package/src/presentation/http/hono/CodemationHonoApiAppFactory.ts +33 -8
- package/src/presentation/http/hono/InternalHonoApiRouteRegistrar.ts +12 -0
- package/src/presentation/http/hono/registrars/ManagedMeHonoApiRouteRegistrar.ts +35 -0
- package/src/presentation/http/hono/registrars/OAuth2HonoApiRouteRegistrar.ts +2 -2
- package/src/presentation/http/routeHandlers/CredentialHttpRouteHandler.ts +28 -0
- package/src/presentation/http/routeHandlers/OAuth2HttpRouteHandlerFactory.ts +98 -41
- package/src/presentation/server/CodemationConsumerConfigLoader.ts +59 -7
- package/src/presentation/server/CodemationPluginDiscovery.ts +5 -0
- package/src/presentation/server/WorkflowDefinitionExportsResolver.ts +18 -0
- package/src/presentation/server/WorkflowModulePathFinder.ts +12 -1
- package/src/presentation/websocket/ManagedWebsocketAuthenticator.ts +50 -0
- package/src/presentation/websocket/WebsocketAuthenticator.types.ts +12 -0
- package/src/presentation/websocket/WorkflowWebsocketServer.ts +24 -3
- package/src/presentation/websocket/WorkflowWebsocketServerFactory.ts +16 -0
- package/src/process/ExecaProcessRunner.ts +41 -0
- package/src/process/ProcessRunner.types.ts +39 -0
- package/src/server.ts +2 -0
- package/src/workflows/InternalWorkflowActivationRegistrar.ts +42 -0
- package/src/workflows/InternalWorkflowDetailRegistrar.ts +33 -0
- package/src/workflows/InternalWorkflowTestRunRegistrar.ts +91 -0
- package/src/workflows/InternalWorkflowsListRegistrar.ts +28 -0
- package/src/workflows/discovery/WorkflowDirectoryDiscoverer.ts +79 -0
- package/tsconfig.json +2 -0
- package/vitest.shared.ts +5 -0
- package/dist/ApiPaths-CLTHphYZ.js.map +0 -1
- package/dist/AppConfigFactory-C6q-CSKb.js.map +0 -1
- package/dist/AppContainerFactory-qaqc-R1D.js.map +0 -1
- package/dist/CodemationAuthoring.types-DZl-sJaM.js.map +0 -1
- package/dist/CodemationConsumerConfigLoader-BeAUS144.js.map +0 -1
- package/dist/CredentialServices-CgxwguAv.js.map +0 -1
- package/dist/PublicFrontendBootstrapFactory-Cb2pLmDd.d.ts +0 -45
- package/dist/PublicFrontendBootstrapJsonCodec-DzqvA0uo.js.map +0 -1
- package/dist/ServerLoggerFactory-BKSIh9Xv.js +0 -98
- package/dist/ServerLoggerFactory-BKSIh9Xv.js.map +0 -1
- package/dist/persistenceServer-vtJAGDat.d.ts +0 -9
- package/src/domain/credentials/OAuth2ConnectServiceFactory.ts +0 -411
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { inject, injectable } from "@codemation/core";
|
|
4
|
+
import type {
|
|
5
|
+
Clock,
|
|
6
|
+
OAuthFlowCallbackArgs,
|
|
7
|
+
OAuthFlowExecutor,
|
|
8
|
+
OAuthFlowStartArgs,
|
|
9
|
+
OAuthFlowStartResult,
|
|
10
|
+
OAuthMaterial,
|
|
11
|
+
} from "@codemation/core";
|
|
12
|
+
|
|
13
|
+
import { ApplicationTokens } from "../applicationTokens";
|
|
14
|
+
import { CredentialFieldEnvOverlayService } from "../domain/credentials/CredentialFieldEnvOverlayService";
|
|
15
|
+
import type { CredentialStore } from "../domain/credentials/CredentialServices";
|
|
16
|
+
import { CredentialMaterialResolver } from "../domain/credentials/CredentialMaterialResolver";
|
|
17
|
+
import { CredentialTypeRegistryImpl } from "../domain/credentials/CredentialTypeRegistryImpl";
|
|
18
|
+
import { OAuth2ProviderRegistry } from "../domain/credentials/OAuth2ProviderRegistry";
|
|
19
|
+
|
|
20
|
+
type PendingState = Readonly<{
|
|
21
|
+
stateToken: string;
|
|
22
|
+
codeVerifier: string;
|
|
23
|
+
instanceId: string;
|
|
24
|
+
typeId: string;
|
|
25
|
+
redirectUri: string;
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* OAuthFlowExecutor for framework (OSS / standalone) mode.
|
|
31
|
+
*
|
|
32
|
+
* Reads clientId from the credential instance's publicConfig and clientSecret
|
|
33
|
+
* from the instance's secret material. Does NOT write tokens back — that is
|
|
34
|
+
* the responsibility of the callback route (a later story).
|
|
35
|
+
*/
|
|
36
|
+
@injectable()
|
|
37
|
+
export class LocalOAuthFlowExecutor implements OAuthFlowExecutor {
|
|
38
|
+
private static readonly stateTtlMs = 10 * 60 * 1_000;
|
|
39
|
+
|
|
40
|
+
private readonly pendingStates = new Map<string, PendingState>();
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
@inject(CredentialTypeRegistryImpl)
|
|
44
|
+
private readonly credentialTypeRegistry: CredentialTypeRegistryImpl,
|
|
45
|
+
@inject(ApplicationTokens.CredentialStore)
|
|
46
|
+
private readonly credentialStore: CredentialStore,
|
|
47
|
+
@inject(CredentialMaterialResolver)
|
|
48
|
+
private readonly credentialMaterialResolver: CredentialMaterialResolver,
|
|
49
|
+
@inject(OAuth2ProviderRegistry)
|
|
50
|
+
private readonly oauth2ProviderRegistry: OAuth2ProviderRegistry,
|
|
51
|
+
@inject(CredentialFieldEnvOverlayService)
|
|
52
|
+
private readonly credentialFieldEnvOverlayService: CredentialFieldEnvOverlayService,
|
|
53
|
+
@inject(ApplicationTokens.Clock)
|
|
54
|
+
private readonly clock: Clock,
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
async start(args: OAuthFlowStartArgs): Promise<OAuthFlowStartResult> {
|
|
58
|
+
const { instanceId } = args;
|
|
59
|
+
if (!instanceId) {
|
|
60
|
+
throw new Error("LocalOAuthFlowExecutor.start requires instanceId; create the credential instance first");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const instance = await this.credentialStore.getInstance(instanceId);
|
|
64
|
+
if (!instance) {
|
|
65
|
+
throw new Error(`LocalOAuthFlowExecutor: credential instance not found: ${instanceId}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const credentialType = this.credentialTypeRegistry.getCredentialType(instance.typeId);
|
|
69
|
+
if (!credentialType) {
|
|
70
|
+
throw new Error(`LocalOAuthFlowExecutor: unknown credential type: ${instance.typeId}`);
|
|
71
|
+
}
|
|
72
|
+
if (credentialType.definition.auth?.kind !== "oauth2") {
|
|
73
|
+
throw new Error(`LocalOAuthFlowExecutor: credential type ${instance.typeId} is not an OAuth2 type`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const auth = credentialType.definition.auth;
|
|
77
|
+
const rawMaterial = await this.credentialMaterialResolver.resolveMaterial(instance);
|
|
78
|
+
const { resolvedPublicConfig, resolvedMaterial: material } = this.credentialFieldEnvOverlayService.apply({
|
|
79
|
+
definition: credentialType.definition,
|
|
80
|
+
publicConfig: instance.publicConfig,
|
|
81
|
+
material: rawMaterial,
|
|
82
|
+
});
|
|
83
|
+
const provider = this.oauth2ProviderRegistry.resolve(credentialType.definition, resolvedPublicConfig);
|
|
84
|
+
const clientId = this.oauth2ProviderRegistry.resolveClientId(auth, resolvedPublicConfig);
|
|
85
|
+
|
|
86
|
+
const scopes = args.scopes.length > 0 ? [...args.scopes] : [...auth.scopes];
|
|
87
|
+
|
|
88
|
+
const stateToken = this.createOpaqueValue();
|
|
89
|
+
const codeVerifier = this.createOpaqueValue();
|
|
90
|
+
const codeChallenge = this.createPkceCodeChallenge(codeVerifier);
|
|
91
|
+
|
|
92
|
+
const nowMs = this.clock.now().getTime();
|
|
93
|
+
|
|
94
|
+
// Evict expired entries on each start call to keep the map bounded.
|
|
95
|
+
this.evictExpired(nowMs);
|
|
96
|
+
|
|
97
|
+
const expiresAt = nowMs + LocalOAuthFlowExecutor.stateTtlMs;
|
|
98
|
+
this.pendingStates.set(stateToken, {
|
|
99
|
+
stateToken,
|
|
100
|
+
codeVerifier,
|
|
101
|
+
instanceId,
|
|
102
|
+
typeId: instance.typeId,
|
|
103
|
+
redirectUri: args.redirectUri,
|
|
104
|
+
expiresAt,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Suppress unused-variable lint for material — it's loaded to validate that the
|
|
108
|
+
// clientSecret field is present before starting the flow, but clientSecret itself is
|
|
109
|
+
// only needed at completeCallback / refresh time.
|
|
110
|
+
void material;
|
|
111
|
+
|
|
112
|
+
const url = new URL(provider.authorizeUrl);
|
|
113
|
+
url.searchParams.set("response_type", "code");
|
|
114
|
+
url.searchParams.set("client_id", clientId);
|
|
115
|
+
url.searchParams.set("redirect_uri", args.redirectUri);
|
|
116
|
+
url.searchParams.set("scope", scopes.join(" "));
|
|
117
|
+
url.searchParams.set("state", stateToken);
|
|
118
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
119
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
120
|
+
url.searchParams.set("access_type", "offline");
|
|
121
|
+
url.searchParams.set("prompt", "consent");
|
|
122
|
+
|
|
123
|
+
return { consentUrl: url.toString(), stateToken };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
lookupInstanceId(stateToken: string): string | undefined {
|
|
127
|
+
return this.pendingStates.get(stateToken)?.instanceId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async completeCallback(args: OAuthFlowCallbackArgs): Promise<OAuthMaterial> {
|
|
131
|
+
const pending = this.pendingStates.get(args.stateToken);
|
|
132
|
+
if (!pending) {
|
|
133
|
+
throw new Error(`LocalOAuthFlowExecutor: state token not found or already used: ${args.stateToken}`);
|
|
134
|
+
}
|
|
135
|
+
if (this.clock.now().getTime() > pending.expiresAt) {
|
|
136
|
+
this.pendingStates.delete(args.stateToken);
|
|
137
|
+
throw new Error("LocalOAuthFlowExecutor: OAuth state token has expired");
|
|
138
|
+
}
|
|
139
|
+
this.pendingStates.delete(args.stateToken);
|
|
140
|
+
|
|
141
|
+
const instance = await this.credentialStore.getInstance(pending.instanceId);
|
|
142
|
+
if (!instance) {
|
|
143
|
+
throw new Error(`LocalOAuthFlowExecutor: credential instance not found: ${pending.instanceId}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const credentialType = this.credentialTypeRegistry.getCredentialType(instance.typeId);
|
|
147
|
+
if (!credentialType || credentialType.definition.auth?.kind !== "oauth2") {
|
|
148
|
+
throw new Error(`LocalOAuthFlowExecutor: credential type ${instance.typeId} is not an OAuth2 type`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const auth = credentialType.definition.auth;
|
|
152
|
+
const rawMaterial = await this.credentialMaterialResolver.resolveMaterial(instance);
|
|
153
|
+
const { resolvedPublicConfig, resolvedMaterial: material } = this.credentialFieldEnvOverlayService.apply({
|
|
154
|
+
definition: credentialType.definition,
|
|
155
|
+
publicConfig: instance.publicConfig,
|
|
156
|
+
material: rawMaterial,
|
|
157
|
+
});
|
|
158
|
+
const provider = this.oauth2ProviderRegistry.resolve(credentialType.definition, resolvedPublicConfig);
|
|
159
|
+
const clientId = this.oauth2ProviderRegistry.resolveClientId(auth, resolvedPublicConfig);
|
|
160
|
+
const clientSecretFieldKey = this.oauth2ProviderRegistry.resolveClientSecretFieldKey(auth);
|
|
161
|
+
const clientSecret = String(material[clientSecretFieldKey] ?? "");
|
|
162
|
+
if (!clientSecret) {
|
|
163
|
+
throw new Error(`LocalOAuthFlowExecutor: clientSecret missing from secret field "${clientSecretFieldKey}"`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const body = this.buildFormBody({
|
|
167
|
+
grant_type: "authorization_code",
|
|
168
|
+
code: args.code,
|
|
169
|
+
code_verifier: pending.codeVerifier,
|
|
170
|
+
client_id: clientId,
|
|
171
|
+
client_secret: clientSecret,
|
|
172
|
+
redirect_uri: pending.redirectUri,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const response = await fetch(provider.tokenUrl, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
178
|
+
body,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const text = await response.text();
|
|
182
|
+
const json = this.parseJson(text);
|
|
183
|
+
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const msg =
|
|
186
|
+
typeof json.error_description === "string"
|
|
187
|
+
? json.error_description
|
|
188
|
+
: typeof json.error === "string"
|
|
189
|
+
? json.error
|
|
190
|
+
: text || "OAuth2 token exchange failed";
|
|
191
|
+
throw new Error(`LocalOAuthFlowExecutor: token exchange failed: ${msg}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return this.toOAuthMaterial(json);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async refresh(args: { typeId: string; instanceId: string; material: OAuthMaterial }): Promise<OAuthMaterial> {
|
|
198
|
+
const { typeId, instanceId, material } = args;
|
|
199
|
+
|
|
200
|
+
const instance = await this.credentialStore.getInstance(instanceId);
|
|
201
|
+
if (!instance) {
|
|
202
|
+
throw new Error(`LocalOAuthFlowExecutor: credential instance not found: ${instanceId}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const credentialType = this.credentialTypeRegistry.getCredentialType(typeId);
|
|
206
|
+
if (!credentialType || credentialType.definition.auth?.kind !== "oauth2") {
|
|
207
|
+
throw new Error(`LocalOAuthFlowExecutor: credential type ${typeId} is not an OAuth2 type`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const auth = credentialType.definition.auth;
|
|
211
|
+
const rawMaterial = await this.credentialMaterialResolver.resolveMaterial(instance);
|
|
212
|
+
const { resolvedPublicConfig, resolvedMaterial: secretMaterial } = this.credentialFieldEnvOverlayService.apply({
|
|
213
|
+
definition: credentialType.definition,
|
|
214
|
+
publicConfig: instance.publicConfig,
|
|
215
|
+
material: rawMaterial,
|
|
216
|
+
});
|
|
217
|
+
const provider = this.oauth2ProviderRegistry.resolve(credentialType.definition, resolvedPublicConfig);
|
|
218
|
+
const clientId = this.oauth2ProviderRegistry.resolveClientId(auth, resolvedPublicConfig);
|
|
219
|
+
const clientSecretFieldKey = this.oauth2ProviderRegistry.resolveClientSecretFieldKey(auth);
|
|
220
|
+
const clientSecret = String(secretMaterial[clientSecretFieldKey] ?? "");
|
|
221
|
+
if (!clientSecret) {
|
|
222
|
+
throw new Error(`LocalOAuthFlowExecutor: clientSecret missing from secret field "${clientSecretFieldKey}"`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!material.refreshToken) {
|
|
226
|
+
throw new Error("LocalOAuthFlowExecutor: no refresh token available");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const body = this.buildFormBody({
|
|
230
|
+
grant_type: "refresh_token",
|
|
231
|
+
refresh_token: material.refreshToken,
|
|
232
|
+
client_id: clientId,
|
|
233
|
+
client_secret: clientSecret,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const response = await fetch(provider.tokenUrl, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
239
|
+
body,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const text = await response.text();
|
|
243
|
+
const json = this.parseJson(text);
|
|
244
|
+
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
const msg =
|
|
247
|
+
typeof json.error_description === "string"
|
|
248
|
+
? json.error_description
|
|
249
|
+
: typeof json.error === "string"
|
|
250
|
+
? json.error
|
|
251
|
+
: text || "OAuth2 refresh failed";
|
|
252
|
+
throw new Error(`LocalOAuthFlowExecutor: token refresh failed: ${msg}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const refreshed = this.toOAuthMaterial(json);
|
|
256
|
+
// Preserve the existing refresh token if the provider omits it from the response.
|
|
257
|
+
if (!refreshed.refreshToken) {
|
|
258
|
+
return { ...refreshed, refreshToken: material.refreshToken };
|
|
259
|
+
}
|
|
260
|
+
return refreshed;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private toOAuthMaterial(json: Record<string, unknown>): OAuthMaterial {
|
|
264
|
+
const accessToken = String(json.access_token ?? "");
|
|
265
|
+
if (!accessToken) {
|
|
266
|
+
throw new Error("LocalOAuthFlowExecutor: token response missing access_token");
|
|
267
|
+
}
|
|
268
|
+
const refreshToken =
|
|
269
|
+
typeof json.refresh_token === "string" && json.refresh_token.length > 0 ? json.refresh_token : undefined;
|
|
270
|
+
const expiresAt = this.resolveExpiresAt(json);
|
|
271
|
+
const grantedScopes =
|
|
272
|
+
typeof json.scope === "string" && json.scope.length > 0
|
|
273
|
+
? json.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
274
|
+
: [];
|
|
275
|
+
return Object.freeze({ accessToken, refreshToken, expiresAt, grantedScopes });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private resolveExpiresAt(json: Record<string, unknown>): string | undefined {
|
|
279
|
+
const expiresIn = Number(json.expires_in);
|
|
280
|
+
if (Number.isFinite(expiresIn) && expiresIn > 0) {
|
|
281
|
+
return new Date(this.clock.now().getTime() + expiresIn * 1000).toISOString();
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private parseJson(text: string): Record<string, unknown> {
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(text) as unknown;
|
|
289
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
290
|
+
} catch {
|
|
291
|
+
return {};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private buildFormBody(fields: Readonly<Record<string, string>>): string {
|
|
296
|
+
return Object.entries(fields)
|
|
297
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
298
|
+
.join("&");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private createOpaqueValue(): string {
|
|
302
|
+
return randomBytes(32).toString("base64url");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private createPkceCodeChallenge(codeVerifier: string): string {
|
|
306
|
+
return createHash("sha256").update(codeVerifier).digest("base64url");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private evictExpired(nowMs: number): void {
|
|
310
|
+
for (const [key, entry] of this.pendingStates) {
|
|
311
|
+
if (nowMs > entry.expiresAt) {
|
|
312
|
+
this.pendingStates.delete(key);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { inject, injectable } from "@codemation/core";
|
|
2
|
+
import type {
|
|
3
|
+
OAuthFlowCallbackArgs,
|
|
4
|
+
OAuthFlowExecutor,
|
|
5
|
+
OAuthFlowStartArgs,
|
|
6
|
+
OAuthFlowStartResult,
|
|
7
|
+
OAuthMaterial,
|
|
8
|
+
} from "@codemation/core";
|
|
9
|
+
import type { LoggerFactory } from "../application/logging/Logger";
|
|
10
|
+
|
|
11
|
+
import { ApplicationTokens } from "../applicationTokens";
|
|
12
|
+
import { PairedFetch } from "../pairing/PairedFetch";
|
|
13
|
+
import { PairingConfigToken } from "../pairing/PairingConfigToken";
|
|
14
|
+
import type { PairingConfig } from "../pairing/pairing.types";
|
|
15
|
+
import { ManagedOAuthRefreshInvalidGrantError } from "./ManagedOAuthRefreshInvalidGrantError";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* OAuthFlowExecutor for managed mode (paired with a control plane).
|
|
19
|
+
*
|
|
20
|
+
* Delegates the entire OAuth dance to the control plane over HMAC-signed calls.
|
|
21
|
+
* Client secrets never leave the control plane.
|
|
22
|
+
*/
|
|
23
|
+
@injectable()
|
|
24
|
+
export class ManagedOAuthFlowExecutor implements OAuthFlowExecutor {
|
|
25
|
+
constructor(
|
|
26
|
+
@inject(PairedFetch) private readonly pairedFetch: PairedFetch,
|
|
27
|
+
@inject(PairingConfigToken) private readonly pairingConfig: PairingConfig,
|
|
28
|
+
@inject(ApplicationTokens.LoggerFactory) private readonly loggers: LoggerFactory,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async start(args: OAuthFlowStartArgs): Promise<OAuthFlowStartResult> {
|
|
32
|
+
const logger = this.loggers.create("codemation.credentials.managed-oauth");
|
|
33
|
+
const url = `${this.pairingConfig.controlPlaneUrl}/internal/oauth/start`;
|
|
34
|
+
const response = await this.pairedFetch.post(url, {
|
|
35
|
+
typeId: args.typeId,
|
|
36
|
+
scopes: args.scopes,
|
|
37
|
+
redirectUri: args.redirectUri,
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const body = await response.text().catch(() => "");
|
|
41
|
+
const excerpt = body.slice(0, 200);
|
|
42
|
+
logger.warn(`ManagedOAuthFlowExecutor.start failed: ${response.status} ${excerpt}`);
|
|
43
|
+
throw new Error(`ManagedOAuthFlowExecutor.start failed: ${response.status} ${excerpt}`);
|
|
44
|
+
}
|
|
45
|
+
const json = (await response.json()) as { consentUrl: string; stateToken: string };
|
|
46
|
+
return { consentUrl: json.consentUrl, stateToken: json.stateToken };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
lookupInstanceId(_stateToken: string): string | undefined {
|
|
50
|
+
// Managed mode — state is owned by the control plane, not the host.
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async completeCallback(args: OAuthFlowCallbackArgs): Promise<OAuthMaterial> {
|
|
55
|
+
const logger = this.loggers.create("codemation.credentials.managed-oauth");
|
|
56
|
+
const url = `${this.pairingConfig.controlPlaneUrl}/internal/oauth/complete`;
|
|
57
|
+
const response = await this.pairedFetch.post(url, {
|
|
58
|
+
stateToken: args.stateToken,
|
|
59
|
+
code: args.code,
|
|
60
|
+
});
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const body = await response.text().catch(() => "");
|
|
63
|
+
const excerpt = body.slice(0, 200);
|
|
64
|
+
logger.warn(`ManagedOAuthFlowExecutor.completeCallback failed: ${response.status} ${excerpt}`);
|
|
65
|
+
throw new Error(`ManagedOAuthFlowExecutor.completeCallback failed: ${response.status} ${excerpt}`);
|
|
66
|
+
}
|
|
67
|
+
return (await response.json()) as OAuthMaterial;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async refresh(args: { typeId: string; instanceId: string; material: OAuthMaterial }): Promise<OAuthMaterial> {
|
|
71
|
+
const { typeId, instanceId, material } = args;
|
|
72
|
+
if (!material.refreshToken) {
|
|
73
|
+
throw new Error("ManagedOAuthFlowExecutor.refresh: no refresh token available");
|
|
74
|
+
}
|
|
75
|
+
const url = `${this.pairingConfig.controlPlaneUrl}/internal/oauth/refresh`;
|
|
76
|
+
const response = await this.pairedFetch.post(url, {
|
|
77
|
+
typeId,
|
|
78
|
+
instanceId,
|
|
79
|
+
refreshToken: material.refreshToken,
|
|
80
|
+
});
|
|
81
|
+
if (response.status === 410) {
|
|
82
|
+
throw new ManagedOAuthRefreshInvalidGrantError(instanceId);
|
|
83
|
+
}
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`ManagedOAuthFlowExecutor.refresh failed: ${response.status}`);
|
|
86
|
+
}
|
|
87
|
+
const refreshed = (await response.json()) as OAuthMaterial;
|
|
88
|
+
// Preserve the existing refresh token if the control plane omits it from the response.
|
|
89
|
+
if (!refreshed.refreshToken) {
|
|
90
|
+
return { ...refreshed, refreshToken: material.refreshToken };
|
|
91
|
+
}
|
|
92
|
+
return refreshed;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when the control plane returns HTTP 410 (invalid_grant) during a refresh.
|
|
3
|
+
* The refresh token is dead — user revoked, token rotated away, etc.
|
|
4
|
+
* The credential cannot be auto-recovered; the user must reconnect via the Connect flow.
|
|
5
|
+
*/
|
|
6
|
+
export class ManagedOAuthRefreshInvalidGrantError extends Error {
|
|
7
|
+
constructor(readonly credentialInstanceId: string) {
|
|
8
|
+
super(
|
|
9
|
+
`Credential ${credentialInstanceId}: refresh token is invalid or revoked (invalid_grant). Reconnect required.`,
|
|
10
|
+
);
|
|
11
|
+
this.name = "ManagedOAuthRefreshInvalidGrantError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when the credential's refresh token is dead (user revoked the grant,
|
|
3
|
+
* or the token was rotated away). The installation cannot auto-recover; the user
|
|
4
|
+
* must reconnect via the broker Connect flow.
|
|
5
|
+
*/
|
|
6
|
+
export class CredentialDisconnectedError extends Error {
|
|
7
|
+
constructor(readonly credentialInstanceId: string) {
|
|
8
|
+
super(`Credential ${credentialInstanceId}: refresh token is invalid or revoked. Reconnect via the Connect flow.`);
|
|
9
|
+
this.name = "CredentialDisconnectedError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
WorkflowRepository,
|
|
8
8
|
} from "@codemation/core";
|
|
9
9
|
|
|
10
|
-
import { CoreTokens, inject, injectable } from "@codemation/core";
|
|
10
|
+
import { CoreTokens, CredentialUnboundError, inject, injectable } from "@codemation/core";
|
|
11
11
|
|
|
12
12
|
import { ApplicationRequestError } from "../../application/ApplicationRequestError";
|
|
13
13
|
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
} from "../../application/contracts/CredentialContractsRegistry";
|
|
18
18
|
|
|
19
19
|
import { ApplicationTokens } from "../../applicationTokens";
|
|
20
|
+
import type { Logger, LoggerFactory } from "../../application/logging/Logger";
|
|
20
21
|
|
|
21
22
|
import { WorkflowCredentialNodeResolver } from "./WorkflowCredentialNodeResolver";
|
|
22
23
|
import { CredentialInstanceService } from "./CredentialInstanceService";
|
|
@@ -24,6 +25,8 @@ import type { CredentialStore, MutableCredentialSessionService } from "./Credent
|
|
|
24
25
|
|
|
25
26
|
@injectable()
|
|
26
27
|
export class CredentialBindingService {
|
|
28
|
+
private readonly logger: Logger;
|
|
29
|
+
|
|
27
30
|
constructor(
|
|
28
31
|
@inject(ApplicationTokens.CredentialStore)
|
|
29
32
|
private readonly credentialStore: CredentialStore,
|
|
@@ -35,7 +38,11 @@ export class CredentialBindingService {
|
|
|
35
38
|
private readonly credentialSessionService: MutableCredentialSessionService,
|
|
36
39
|
@inject(WorkflowCredentialNodeResolver)
|
|
37
40
|
private readonly workflowCredentialNodeResolver: WorkflowCredentialNodeResolver,
|
|
38
|
-
|
|
41
|
+
@inject(ApplicationTokens.LoggerFactory)
|
|
42
|
+
loggerFactory: LoggerFactory,
|
|
43
|
+
) {
|
|
44
|
+
this.logger = loggerFactory.create("CredentialBindingService");
|
|
45
|
+
}
|
|
39
46
|
|
|
40
47
|
async upsertBinding(
|
|
41
48
|
args: Readonly<{ workflowId: string; nodeId: string; slotKey: string; instanceId: CredentialInstanceId }>,
|
|
@@ -63,6 +70,51 @@ export class CredentialBindingService {
|
|
|
63
70
|
return binding;
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
async assertRequiredCredentialsBound(workflowId: string): Promise<void> {
|
|
74
|
+
const workflow = this.requireWorkflow(workflowId);
|
|
75
|
+
const bindings = await this.credentialStore.listBindingsByWorkflowId(workflowId);
|
|
76
|
+
const boundKeys = new Set(bindings.map((b) => this.toBindingKeyString(b.key)));
|
|
77
|
+
const unboundByDb = this.workflowCredentialNodeResolver
|
|
78
|
+
.listSlots(workflow)
|
|
79
|
+
.filter((slot) => !slot.requirement.optional)
|
|
80
|
+
.filter(
|
|
81
|
+
(slot) =>
|
|
82
|
+
!boundKeys.has(
|
|
83
|
+
this.toBindingKeyString({ workflowId, nodeId: slot.nodeId, slotKey: slot.requirement.slotKey }),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
if (unboundByDb.length === 0) return;
|
|
87
|
+
// Confirm each apparently-unbound slot by attempting session resolution. A custom
|
|
88
|
+
// CredentialSessionService (e.g. a test harness) can satisfy slots that have no DB
|
|
89
|
+
// binding row; only slots that still fail are truly unresolvable.
|
|
90
|
+
const confirmed = [];
|
|
91
|
+
for (const slot of unboundByDb) {
|
|
92
|
+
try {
|
|
93
|
+
await this.credentialSessionService.getSession({
|
|
94
|
+
workflowId,
|
|
95
|
+
nodeId: slot.nodeId,
|
|
96
|
+
slotKey: slot.requirement.slotKey,
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof CredentialUnboundError)) {
|
|
100
|
+
this.logger.debug(
|
|
101
|
+
`CredentialBindingService: unexpected error resolving session for slot ${slot.requirement.slotKey} on ${slot.nodeId}`,
|
|
102
|
+
error instanceof Error ? error : undefined,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
confirmed.push(slot);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (confirmed.length === 0) return;
|
|
109
|
+
const descriptions = confirmed
|
|
110
|
+
.map((slot) => `"${slot.requirement.label}" on ${slot.nodeName ?? slot.nodeId}`)
|
|
111
|
+
.join(", ");
|
|
112
|
+
throw new ApplicationRequestError(
|
|
113
|
+
400,
|
|
114
|
+
`Cannot run workflow: required credential slot${confirmed.length > 1 ? "s" : ""} not bound: ${descriptions}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
66
118
|
async listWorkflowHealth(workflowId: string): Promise<WorkflowCredentialHealthDto> {
|
|
67
119
|
const workflow = this.requireWorkflow(workflowId);
|
|
68
120
|
const bindings = await this.credentialStore.listBindingsByWorkflowId(workflowId);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by {@link CredentialSecretCipher.decrypt} when the credential's stored
|
|
3
|
+
* `encryptionKeyId` does not match the current master key's id.
|
|
4
|
+
*
|
|
5
|
+
* This indicates the `CODEMATION_CREDENTIALS_MASTER_KEY` environment variable has
|
|
6
|
+
* been rotated since the credential was encrypted. The operator must re-bind the
|
|
7
|
+
* affected credential (which re-encrypts it with the new key).
|
|
8
|
+
*
|
|
9
|
+
* See {@link docs/security-boundary.md} for the key rotation contract.
|
|
10
|
+
*/
|
|
11
|
+
export class CredentialKeyRotatedError extends Error {
|
|
12
|
+
readonly storedKeyId: string;
|
|
13
|
+
|
|
14
|
+
constructor(storedKeyId: string) {
|
|
15
|
+
super(
|
|
16
|
+
`Credential was encrypted with key "${storedKeyId}" but the current master key produces a different id. ` +
|
|
17
|
+
`Re-bind the credential to re-encrypt it with the active key.`,
|
|
18
|
+
);
|
|
19
|
+
this.name = "CredentialKeyRotatedError";
|
|
20
|
+
this.storedKeyId = storedKeyId;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
import { inject, injectable } from "@codemation/core";
|
|
4
4
|
|
|
@@ -6,13 +6,26 @@ import { ApplicationTokens } from "../../applicationTokens";
|
|
|
6
6
|
import type { AppConfig } from "../../presentation/config/AppConfig";
|
|
7
7
|
|
|
8
8
|
import type { JsonRecord } from "./CredentialServices";
|
|
9
|
+
import { CredentialKeyRotatedError } from "./CredentialKeyRotatedError";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Schema versions:
|
|
13
|
+
* 1 — key = SHA-256(rawValue) (legacy, read-only support retained for migration)
|
|
14
|
+
* 2 — key = HKDF-SHA-256(rawKey32Bytes, ...) (current)
|
|
15
|
+
*
|
|
16
|
+
* All new encryptions are written as v2. Existing v1 records can still be
|
|
17
|
+
* decrypted so operators can re-encrypt at their own pace (re-bind the
|
|
18
|
+
* credential in the UI, or run the one-shot re-encrypt script).
|
|
19
|
+
*/
|
|
10
20
|
@injectable()
|
|
11
21
|
export class CredentialSecretCipher {
|
|
12
22
|
private static readonly algorithm = "aes-256-gcm";
|
|
13
|
-
private static readonly
|
|
23
|
+
private static readonly currentSchemaVersion = 2;
|
|
14
24
|
private static readonly ivLength = 12;
|
|
15
25
|
|
|
26
|
+
private static readonly HKDF_SALT = "codemation/credential-cipher/v1";
|
|
27
|
+
private static readonly HKDF_INFO = "aes-256-gcm-key";
|
|
28
|
+
|
|
16
29
|
constructor(
|
|
17
30
|
@inject(ApplicationTokens.AppConfig)
|
|
18
31
|
private readonly appConfig: AppConfig,
|
|
@@ -24,7 +37,7 @@ export class CredentialSecretCipher {
|
|
|
24
37
|
schemaVersion: number;
|
|
25
38
|
}> {
|
|
26
39
|
const iv = randomBytes(CredentialSecretCipher.ivLength);
|
|
27
|
-
const cipher = createCipheriv(CredentialSecretCipher.algorithm, this.
|
|
40
|
+
const cipher = createCipheriv(CredentialSecretCipher.algorithm, this.resolveKeyMaterialV2(), iv);
|
|
28
41
|
const plaintext = Buffer.from(JSON.stringify(value), "utf8");
|
|
29
42
|
// eslint-disable-next-line codemation/no-buffer-everything -- AES-GCM credential cipher operates on bounded KB-sized JSON payloads; streaming crypto is not applicable here.
|
|
30
43
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
@@ -33,7 +46,7 @@ export class CredentialSecretCipher {
|
|
|
33
46
|
// eslint-disable-next-line codemation/no-buffer-everything -- AES-GCM credential cipher operates on bounded KB-sized JSON payloads; streaming crypto is not applicable here.
|
|
34
47
|
encryptedJson: Buffer.concat([iv, authTag, encrypted]).toString("base64"),
|
|
35
48
|
encryptionKeyId: this.resolveKeyId(),
|
|
36
|
-
schemaVersion: CredentialSecretCipher.
|
|
49
|
+
schemaVersion: CredentialSecretCipher.currentSchemaVersion,
|
|
37
50
|
};
|
|
38
51
|
}
|
|
39
52
|
|
|
@@ -44,19 +57,48 @@ export class CredentialSecretCipher {
|
|
|
44
57
|
schemaVersion: number;
|
|
45
58
|
}>,
|
|
46
59
|
): JsonRecord {
|
|
60
|
+
// resolveKeyMaterialV2 / resolveKeyMaterialV1 both throw if env is missing
|
|
61
|
+
// — that check must come before the key-id comparison.
|
|
62
|
+
const keyMaterial = (record.schemaVersion ?? 1) >= 2 ? this.resolveKeyMaterialV2() : this.resolveKeyMaterialV1();
|
|
63
|
+
|
|
64
|
+
const currentKeyId = this.resolveKeyId();
|
|
65
|
+
if (record.encryptionKeyId !== currentKeyId) {
|
|
66
|
+
throw new CredentialKeyRotatedError(record.encryptionKeyId);
|
|
67
|
+
}
|
|
47
68
|
// eslint-disable-next-line codemation/no-buffer-everything -- AES-GCM credential cipher operates on bounded KB-sized JSON payloads; streaming crypto is not applicable here.
|
|
48
69
|
const packed = Buffer.from(record.encryptedJson, "base64");
|
|
49
70
|
const iv = packed.subarray(0, CredentialSecretCipher.ivLength);
|
|
50
71
|
const authTag = packed.subarray(CredentialSecretCipher.ivLength, CredentialSecretCipher.ivLength + 16);
|
|
51
72
|
const encrypted = packed.subarray(CredentialSecretCipher.ivLength + 16);
|
|
52
|
-
const decipher = createDecipheriv(CredentialSecretCipher.algorithm,
|
|
73
|
+
const decipher = createDecipheriv(CredentialSecretCipher.algorithm, keyMaterial, iv);
|
|
53
74
|
decipher.setAuthTag(authTag);
|
|
54
75
|
// eslint-disable-next-line codemation/no-buffer-everything -- AES-GCM credential cipher operates on bounded KB-sized JSON payloads; streaming crypto is not applicable here.
|
|
55
76
|
const plaintext = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
56
77
|
return JSON.parse(plaintext) as JsonRecord;
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Current (v2) key derivation: HKDF-SHA-256 with a fixed application salt and info label.
|
|
82
|
+
* Input must be a base64-encoded 32-byte value (`CODEMATION_CREDENTIALS_MASTER_KEY`).
|
|
83
|
+
*/
|
|
84
|
+
private resolveKeyMaterialV2(): Buffer {
|
|
85
|
+
const ikm = this.resolveBase64Key32Bytes();
|
|
86
|
+
return Buffer.from(
|
|
87
|
+
hkdfSync(
|
|
88
|
+
"sha256",
|
|
89
|
+
ikm,
|
|
90
|
+
Buffer.from(CredentialSecretCipher.HKDF_SALT, "utf8"),
|
|
91
|
+
Buffer.from(CredentialSecretCipher.HKDF_INFO, "utf8"),
|
|
92
|
+
32,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Legacy (v1) key derivation: SHA-256 of the raw env string.
|
|
99
|
+
* Retained for decrypt-side backward compatibility only.
|
|
100
|
+
*/
|
|
101
|
+
private resolveKeyMaterialV1(): Buffer {
|
|
60
102
|
const rawValue = this.appConfig.env.CODEMATION_CREDENTIALS_MASTER_KEY;
|
|
61
103
|
if (!rawValue || rawValue.trim().length === 0) {
|
|
62
104
|
throw new Error("CODEMATION_CREDENTIALS_MASTER_KEY is required to encrypt database-managed credentials.");
|
|
@@ -64,6 +106,26 @@ export class CredentialSecretCipher {
|
|
|
64
106
|
return createHash("sha256").update(rawValue).digest();
|
|
65
107
|
}
|
|
66
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Validates and returns the raw 32-byte key material from the env var.
|
|
111
|
+
* Throws if the env var is absent or does not decode to exactly 32 bytes.
|
|
112
|
+
*/
|
|
113
|
+
private resolveBase64Key32Bytes(): Buffer {
|
|
114
|
+
const rawValue = this.appConfig.env.CODEMATION_CREDENTIALS_MASTER_KEY;
|
|
115
|
+
if (!rawValue || rawValue.trim().length === 0) {
|
|
116
|
+
throw new Error("CODEMATION_CREDENTIALS_MASTER_KEY is required to encrypt database-managed credentials.");
|
|
117
|
+
}
|
|
118
|
+
// eslint-disable-next-line codemation/no-buffer-everything -- key material is always 32 bytes; bounded by validation below.
|
|
119
|
+
const decoded = Buffer.from(rawValue.trim(), "base64");
|
|
120
|
+
if (decoded.length !== 32) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`CODEMATION_CREDENTIALS_MASTER_KEY must be a base64-encoded 32-byte value (got ${decoded.length} bytes). ` +
|
|
123
|
+
`Generate a valid key with: openssl rand -base64 32`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return decoded;
|
|
127
|
+
}
|
|
128
|
+
|
|
67
129
|
private resolveKeyId(): string {
|
|
68
130
|
const rawValue = this.appConfig.env.CODEMATION_CREDENTIALS_MASTER_KEY;
|
|
69
131
|
return createHash("sha256")
|