create-safest-tools 0.2.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/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { sha256 } from "./report-crypto";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import type { OperatorSession } from "./report-types";
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
|
|
9
|
+
function now(): string { return new Date().toISOString(); }
|
|
10
|
+
|
|
11
|
+
function base64Url(bytes: Uint8Array): string {
|
|
12
|
+
let binary = "";
|
|
13
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
14
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fromBase64Url(value: string): Uint8Array {
|
|
18
|
+
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
19
|
+
const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="));
|
|
20
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function randomToken(bytes = 32): string {
|
|
24
|
+
const value = new Uint8Array(bytes);
|
|
25
|
+
crypto.getRandomValues(value);
|
|
26
|
+
return base64Url(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function encryptionKey(secret: string): Promise<CryptoKey> {
|
|
30
|
+
if (secret.length < 32) throw new ApiError(503, "credential_encryption_unavailable", "Credential encryption is not configured.");
|
|
31
|
+
return crypto.subtle.importKey("raw", await crypto.subtle.digest("SHA-256", encoder.encode(secret)), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function encryptCredential(secret: string, plaintext: string, context: string): Promise<string> {
|
|
35
|
+
const iv = new Uint8Array(12);
|
|
36
|
+
crypto.getRandomValues(iv);
|
|
37
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
|
|
38
|
+
{ name: "AES-GCM", iv, additionalData: encoder.encode(context) },
|
|
39
|
+
await encryptionKey(secret),
|
|
40
|
+
encoder.encode(plaintext),
|
|
41
|
+
));
|
|
42
|
+
const packed = new Uint8Array(iv.byteLength + ciphertext.byteLength);
|
|
43
|
+
packed.set(iv);
|
|
44
|
+
packed.set(ciphertext, iv.byteLength);
|
|
45
|
+
return `v1.${base64Url(packed)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function decryptCredential(secret: string, value: string, context: string): Promise<string> {
|
|
49
|
+
if (!value.startsWith("v1.")) throw new ApiError(503, "credential_ciphertext_invalid", "Stored connection credentials are invalid.");
|
|
50
|
+
try {
|
|
51
|
+
const packed = fromBase64Url(value.slice(3));
|
|
52
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
53
|
+
{ name: "AES-GCM", iv: packed.slice(0, 12), additionalData: encoder.encode(context) },
|
|
54
|
+
await encryptionKey(secret),
|
|
55
|
+
packed.slice(12),
|
|
56
|
+
);
|
|
57
|
+
return decoder.decode(plaintext);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new ApiError(503, "credential_decryption_failed", "Stored connection credentials could not be decrypted.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface OAuthConfiguration {
|
|
64
|
+
authorizationUrl: string;
|
|
65
|
+
tokenUrl: string;
|
|
66
|
+
revokeUrl: string | null;
|
|
67
|
+
clientId: string;
|
|
68
|
+
clientSecretReference: string | null;
|
|
69
|
+
scopes: string[];
|
|
70
|
+
authorizationParameters: Record<string, string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function publicHttpsUrl(value: unknown, field: string): string {
|
|
74
|
+
if (typeof value !== "string" || value.length > 2_048) throw new ApiError(409, "oauth_configuration_invalid", `${field} is required.`);
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(value);
|
|
77
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/gu, "");
|
|
78
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash
|
|
79
|
+
|| host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")
|
|
80
|
+
|| host === "0.0.0.0" || host === "127.0.0.1" || host === "::1") throw new Error("unsafe");
|
|
81
|
+
return url.href;
|
|
82
|
+
} catch {
|
|
83
|
+
throw new ApiError(409, "oauth_configuration_invalid", `${field} must be a public HTTPS URL.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function oauthConfiguration(value: string): OAuthConfiguration {
|
|
88
|
+
let raw: Record<string, unknown>;
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(value) as unknown;
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
|
|
92
|
+
raw = parsed as Record<string, unknown>;
|
|
93
|
+
} catch {
|
|
94
|
+
throw new ApiError(409, "oauth_configuration_invalid", "The published OAuth configuration is invalid.");
|
|
95
|
+
}
|
|
96
|
+
const clientId = typeof raw.client_id === "string" ? raw.client_id.trim() : "";
|
|
97
|
+
if (!clientId || clientId.length > 500) throw new ApiError(409, "oauth_configuration_invalid", "oauth_configuration.client_id is required.");
|
|
98
|
+
const scopes = Array.isArray(raw.scopes) && raw.scopes.length <= 100
|
|
99
|
+
? raw.scopes.map((scope) => typeof scope === "string" ? scope.trim() : "").filter(Boolean) : [];
|
|
100
|
+
const authorizationParameters: Record<string, string> = {};
|
|
101
|
+
if (raw.authorization_parameters && typeof raw.authorization_parameters === "object" && !Array.isArray(raw.authorization_parameters)) {
|
|
102
|
+
for (const [key, entry] of Object.entries(raw.authorization_parameters as Record<string, unknown>)) {
|
|
103
|
+
if (!/^[A-Za-z0-9_.~-]{1,100}$/u.test(key) || typeof entry !== "string" || entry.length > 1_000
|
|
104
|
+
|| ["state", "redirect_uri", "client_id", "code_challenge", "response_type"].includes(key)) {
|
|
105
|
+
throw new ApiError(409, "oauth_configuration_invalid", "authorization_parameters contains an unsafe value.");
|
|
106
|
+
}
|
|
107
|
+
authorizationParameters[key] = entry;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const secretReference = raw.client_secret_reference === undefined || raw.client_secret_reference === null
|
|
111
|
+
? null : typeof raw.client_secret_reference === "string" && /^OAUTH_[A-Z0-9_]{2,96}$/u.test(raw.client_secret_reference)
|
|
112
|
+
? raw.client_secret_reference : (() => { throw new ApiError(409, "oauth_configuration_invalid", "client_secret_reference is invalid."); })();
|
|
113
|
+
return {
|
|
114
|
+
authorizationUrl: publicHttpsUrl(raw.authorization_url, "authorization_url"),
|
|
115
|
+
tokenUrl: publicHttpsUrl(raw.token_url, "token_url"),
|
|
116
|
+
revokeUrl: raw.revoke_url ? publicHttpsUrl(raw.revoke_url, "revoke_url") : null,
|
|
117
|
+
clientId,
|
|
118
|
+
clientSecretReference: secretReference,
|
|
119
|
+
scopes,
|
|
120
|
+
authorizationParameters,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function secretBinding(env: Env, reference: string | null): string | null {
|
|
125
|
+
if (!reference) return null;
|
|
126
|
+
const value = (env as unknown as Record<string, unknown>)[reference];
|
|
127
|
+
return typeof value === "string" && value.length >= 8 ? value : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readJsonResponse(response: Response): Promise<Record<string, unknown>> {
|
|
131
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
132
|
+
if (bytes.byteLength > 65_536) throw new ApiError(502, "oauth_provider_response_too_large", "The OAuth provider response exceeded the safe limit.");
|
|
133
|
+
try {
|
|
134
|
+
const value = JSON.parse(decoder.decode(bytes)) as unknown;
|
|
135
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
|
|
136
|
+
return value as Record<string, unknown>;
|
|
137
|
+
} catch {
|
|
138
|
+
throw new ApiError(502, "oauth_provider_response_invalid", "The OAuth provider returned invalid JSON.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function tokenFields(value: Record<string, unknown>): { accessToken: string; refreshToken: string | null; tokenType: string; expiresAt: string | null; scopes: string[] } {
|
|
143
|
+
const accessToken = typeof value.access_token === "string" ? value.access_token : "";
|
|
144
|
+
if (!accessToken || accessToken.length > 16_384 || /[\r\n]/u.test(accessToken)) throw new ApiError(502, "oauth_access_token_missing", "The OAuth provider did not return a usable access token.");
|
|
145
|
+
const refreshToken = typeof value.refresh_token === "string" && value.refresh_token.length <= 16_384 && !/[\r\n]/u.test(value.refresh_token) ? value.refresh_token : null;
|
|
146
|
+
const tokenType = typeof value.token_type === "string" && /^[A-Za-z][A-Za-z0-9._~-]{0,63}$/u.test(value.token_type) ? value.token_type : "Bearer";
|
|
147
|
+
const expiresIn = Number(value.expires_in);
|
|
148
|
+
const expiresAt = Number.isFinite(expiresIn) && expiresIn > 0
|
|
149
|
+
? new Date(Date.now() + Math.min(expiresIn, 31_536_000) * 1_000).toISOString() : null;
|
|
150
|
+
const scopes = typeof value.scope === "string" ? value.scope.split(/\s+/u).filter(Boolean).slice(0, 100) : [];
|
|
151
|
+
return { accessToken, refreshToken, tokenType, expiresAt, scopes };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface OAuthVersionRow {
|
|
155
|
+
connectionId: string;
|
|
156
|
+
versionId: string;
|
|
157
|
+
oauthConfigurationJson: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function oauthVersion(db: D1Database, connectionId: string): Promise<OAuthVersionRow> {
|
|
161
|
+
const row = await db.prepare(`
|
|
162
|
+
SELECT d.id AS connectionId, v.id AS versionId, v.oauth_configuration_json AS oauthConfigurationJson
|
|
163
|
+
FROM connection_definitions d JOIN connection_versions v ON v.id = d.active_version_id
|
|
164
|
+
WHERE d.id = ?1 AND d.status != 'archived' AND v.credential_strategy = 'oauth2_authorization_code'
|
|
165
|
+
AND v.retired_at IS NULL LIMIT 1
|
|
166
|
+
`).bind(connectionId).first<OAuthVersionRow>();
|
|
167
|
+
if (!row) throw new ApiError(409, "oauth_connection_unavailable", "The connection has no active authorization-code OAuth version.");
|
|
168
|
+
return row;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function startConnectionOAuth(
|
|
172
|
+
env: Env,
|
|
173
|
+
connectionId: string,
|
|
174
|
+
session: OperatorSession,
|
|
175
|
+
): Promise<{ authorizationUrl: string; expiresAt: string }> {
|
|
176
|
+
const version = await oauthVersion(env.DB, connectionId);
|
|
177
|
+
const configuration = oauthConfiguration(version.oauthConfigurationJson);
|
|
178
|
+
const state = randomToken(32);
|
|
179
|
+
const verifier = randomToken(48);
|
|
180
|
+
const challenge = base64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(verifier))));
|
|
181
|
+
const redirectUri = `${env.PUBLIC_BASE_URL.replace(/\/$/u, "")}/v1/admin/connections/oauth/callback`;
|
|
182
|
+
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
|
|
183
|
+
const timestamp = now();
|
|
184
|
+
await env.DB.prepare(`
|
|
185
|
+
INSERT INTO connection_oauth_states (
|
|
186
|
+
id, connection_id, actor_id, state_hash, pkce_verifier_ciphertext,
|
|
187
|
+
redirect_uri, requested_scopes_json, expires_at, created_at
|
|
188
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
189
|
+
`).bind(
|
|
190
|
+
crypto.randomUUID(), connectionId, session.actor.id, await sha256(state),
|
|
191
|
+
await encryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, verifier, `oauth-state:${connectionId}`),
|
|
192
|
+
redirectUri, canonicalJson(configuration.scopes), expiresAt, timestamp,
|
|
193
|
+
).run();
|
|
194
|
+
const authorization = new URL(configuration.authorizationUrl);
|
|
195
|
+
authorization.searchParams.set("response_type", "code");
|
|
196
|
+
authorization.searchParams.set("client_id", configuration.clientId);
|
|
197
|
+
authorization.searchParams.set("redirect_uri", redirectUri);
|
|
198
|
+
authorization.searchParams.set("state", state);
|
|
199
|
+
authorization.searchParams.set("code_challenge", challenge);
|
|
200
|
+
authorization.searchParams.set("code_challenge_method", "S256");
|
|
201
|
+
if (configuration.scopes.length) authorization.searchParams.set("scope", configuration.scopes.join(" "));
|
|
202
|
+
for (const [key, value] of Object.entries(configuration.authorizationParameters)) authorization.searchParams.set(key, value);
|
|
203
|
+
return { authorizationUrl: authorization.href, expiresAt };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function tokenExchange(
|
|
207
|
+
env: Env,
|
|
208
|
+
configuration: OAuthConfiguration,
|
|
209
|
+
values: Record<string, string>,
|
|
210
|
+
): Promise<Record<string, unknown>> {
|
|
211
|
+
const parameters = new URLSearchParams({ client_id: configuration.clientId, ...values });
|
|
212
|
+
const clientSecret = secretBinding(env, configuration.clientSecretReference);
|
|
213
|
+
if (configuration.clientSecretReference && !clientSecret) throw new ApiError(503, "oauth_client_secret_unavailable", "The OAuth client secret binding is unavailable.");
|
|
214
|
+
if (clientSecret) parameters.set("client_secret", clientSecret);
|
|
215
|
+
let response: Response;
|
|
216
|
+
try {
|
|
217
|
+
response = await fetch(configuration.tokenUrl, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
|
220
|
+
body: parameters,
|
|
221
|
+
redirect: "error",
|
|
222
|
+
signal: AbortSignal.timeout(15_000),
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
throw new ApiError(503, "oauth_provider_unavailable", "The OAuth provider could not be reached.");
|
|
226
|
+
}
|
|
227
|
+
const result = await readJsonResponse(response);
|
|
228
|
+
if (!response.ok) throw new ApiError(502, "oauth_token_exchange_failed", "The OAuth provider rejected the token request.", { provider_status: response.status, provider_error: typeof result.error === "string" ? result.error.slice(0, 200) : null });
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function completeConnectionOAuth(
|
|
233
|
+
env: Env,
|
|
234
|
+
state: string,
|
|
235
|
+
code: string,
|
|
236
|
+
session: OperatorSession,
|
|
237
|
+
): Promise<{ connectionId: string; connectionVersionId: string; credentialId: string }> {
|
|
238
|
+
if (state.length < 32 || state.length > 500 || code.length < 1 || code.length > 4_096) throw new ApiError(400, "oauth_callback_invalid", "The OAuth callback is invalid.");
|
|
239
|
+
const stored = await env.DB.prepare(`
|
|
240
|
+
SELECT id, connection_id AS connectionId, actor_id AS actorId,
|
|
241
|
+
pkce_verifier_ciphertext AS verifierCiphertext, redirect_uri AS redirectUri,
|
|
242
|
+
requested_scopes_json AS requestedScopesJson, expires_at AS expiresAt, consumed_at AS consumedAt
|
|
243
|
+
FROM connection_oauth_states WHERE state_hash = ?1 LIMIT 1
|
|
244
|
+
`).bind(await sha256(state)).first<{
|
|
245
|
+
id: string; connectionId: string; actorId: string; verifierCiphertext: string;
|
|
246
|
+
redirectUri: string; requestedScopesJson: string; expiresAt: string; consumedAt: string | null;
|
|
247
|
+
}>();
|
|
248
|
+
if (!stored || stored.actorId !== session.actor.id || stored.consumedAt || stored.expiresAt <= now()) {
|
|
249
|
+
throw new ApiError(400, "oauth_state_invalid", "The OAuth state is invalid, expired, consumed, or belongs to another operator.");
|
|
250
|
+
}
|
|
251
|
+
const version = await oauthVersion(env.DB, stored.connectionId);
|
|
252
|
+
const configuration = oauthConfiguration(version.oauthConfigurationJson);
|
|
253
|
+
const verifier = await decryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, stored.verifierCiphertext, `oauth-state:${stored.connectionId}`);
|
|
254
|
+
const timestamp = now();
|
|
255
|
+
const claimed = await env.DB.prepare(`
|
|
256
|
+
UPDATE connection_oauth_states SET consumed_at = ?2
|
|
257
|
+
WHERE id = ?1 AND consumed_at IS NULL AND expires_at > ?2
|
|
258
|
+
`).bind(stored.id, timestamp).run();
|
|
259
|
+
if (claimed.meta.changes !== 1) throw new ApiError(400, "oauth_state_consumed", "The OAuth state was already consumed.");
|
|
260
|
+
const result = tokenFields(await tokenExchange(env, configuration, {
|
|
261
|
+
grant_type: "authorization_code", code, redirect_uri: stored.redirectUri, code_verifier: verifier,
|
|
262
|
+
}));
|
|
263
|
+
const credentialId = crypto.randomUUID();
|
|
264
|
+
await env.DB.batch([
|
|
265
|
+
env.DB.prepare(`
|
|
266
|
+
INSERT INTO connection_credentials (
|
|
267
|
+
id, connection_version_id, strategy, encrypted_access_token,
|
|
268
|
+
encrypted_refresh_token, token_type, granted_scopes_json, expires_at,
|
|
269
|
+
status, created_at, updated_at
|
|
270
|
+
) VALUES (?1, ?2, 'oauth2_authorization_code', ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?8)
|
|
271
|
+
ON CONFLICT(connection_version_id) DO UPDATE SET
|
|
272
|
+
encrypted_access_token = excluded.encrypted_access_token,
|
|
273
|
+
encrypted_refresh_token = excluded.encrypted_refresh_token,
|
|
274
|
+
token_type = excluded.token_type, granted_scopes_json = excluded.granted_scopes_json,
|
|
275
|
+
expires_at = excluded.expires_at, status = 'active', last_error_code = NULL,
|
|
276
|
+
updated_at = excluded.updated_at
|
|
277
|
+
`).bind(
|
|
278
|
+
credentialId, version.versionId,
|
|
279
|
+
await encryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, result.accessToken, `oauth-access:${version.versionId}`),
|
|
280
|
+
result.refreshToken ? await encryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, result.refreshToken, `oauth-refresh:${version.versionId}`) : null,
|
|
281
|
+
result.tokenType, canonicalJson(result.scopes.length ? result.scopes : JSON.parse(stored.requestedScopesJson)),
|
|
282
|
+
result.expiresAt, timestamp,
|
|
283
|
+
),
|
|
284
|
+
env.DB.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'connection.oauth_connected', ?3, ?4, 'connection', ?5, ?6, ?7)`)
|
|
285
|
+
.bind(crypto.randomUUID(), `connection-oauth:${stored.id}`, session.actor.type, session.actor.id, stored.connectionId, canonicalJson({ connectionVersionId: version.versionId, scopes: result.scopes }), timestamp),
|
|
286
|
+
]);
|
|
287
|
+
const current = await env.DB.prepare(`SELECT id FROM connection_credentials WHERE connection_version_id = ?1`).bind(version.versionId).first<{ id: string }>();
|
|
288
|
+
return { connectionId: stored.connectionId, connectionVersionId: version.versionId, credentialId: current?.id ?? credentialId };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function revokeConnectionOAuth(
|
|
292
|
+
env: Env,
|
|
293
|
+
connectionId: string,
|
|
294
|
+
session: OperatorSession,
|
|
295
|
+
): Promise<{ connectionId: string; revoked: boolean }> {
|
|
296
|
+
const version = await oauthVersion(env.DB, connectionId);
|
|
297
|
+
const configuration = oauthConfiguration(version.oauthConfigurationJson);
|
|
298
|
+
const credential = await env.DB.prepare(`SELECT id, encrypted_access_token AS accessToken FROM connection_credentials WHERE connection_version_id = ?1 AND status != 'revoked' LIMIT 1`)
|
|
299
|
+
.bind(version.versionId).first<{ id: string; accessToken: string | null }>();
|
|
300
|
+
if (!credential) return { connectionId, revoked: false };
|
|
301
|
+
if (configuration.revokeUrl && credential.accessToken) {
|
|
302
|
+
const token = await decryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, credential.accessToken, `oauth-access:${version.versionId}`);
|
|
303
|
+
const parameters = new URLSearchParams({ token, client_id: configuration.clientId });
|
|
304
|
+
const clientSecret = secretBinding(env, configuration.clientSecretReference);
|
|
305
|
+
if (clientSecret) parameters.set("client_secret", clientSecret);
|
|
306
|
+
let response: Response;
|
|
307
|
+
try {
|
|
308
|
+
response = await fetch(configuration.revokeUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: parameters, redirect: "error", signal: AbortSignal.timeout(15_000) });
|
|
309
|
+
} catch {
|
|
310
|
+
throw new ApiError(503, "oauth_provider_unavailable", "The OAuth provider could not be reached for revocation.");
|
|
311
|
+
}
|
|
312
|
+
if (!response.ok) throw new ApiError(502, "oauth_revocation_failed", "The OAuth provider rejected revocation.", { provider_status: response.status });
|
|
313
|
+
}
|
|
314
|
+
const timestamp = now();
|
|
315
|
+
await env.DB.batch([
|
|
316
|
+
env.DB.prepare(`UPDATE connection_credentials SET status = 'revoked', encrypted_access_token = NULL, encrypted_refresh_token = NULL, updated_at = ?2 WHERE id = ?1`).bind(credential.id, timestamp),
|
|
317
|
+
env.DB.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'connection.oauth_revoked', ?3, ?4, 'connection', ?5, ?6, ?7)`)
|
|
318
|
+
.bind(crypto.randomUUID(), `connection-oauth-revoke:${credential.id}`, session.actor.type, session.actor.id, connectionId, canonicalJson({ connectionVersionId: version.versionId }), timestamp),
|
|
319
|
+
]);
|
|
320
|
+
return { connectionId, revoked: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function resolveOAuthAccessToken(env: Env, connectionVersionId: string): Promise<{ token: string; tokenType: string }> {
|
|
324
|
+
const credential = await env.DB.prepare(`
|
|
325
|
+
SELECT c.id, c.encrypted_access_token AS accessToken, c.encrypted_refresh_token AS refreshToken,
|
|
326
|
+
c.token_type AS tokenType, c.expires_at AS expiresAt, c.status, c.updated_at AS updatedAt,
|
|
327
|
+
v.oauth_configuration_json AS oauthConfigurationJson
|
|
328
|
+
FROM connection_credentials c JOIN connection_versions v ON v.id = c.connection_version_id
|
|
329
|
+
WHERE c.connection_version_id = ?1 LIMIT 1
|
|
330
|
+
`).bind(connectionVersionId).first<{
|
|
331
|
+
id: string; accessToken: string | null; refreshToken: string | null; tokenType: string | null;
|
|
332
|
+
expiresAt: string | null; status: string; updatedAt: string; oauthConfigurationJson: string;
|
|
333
|
+
}>();
|
|
334
|
+
if (!credential || credential.status !== "active" || !credential.accessToken) throw new ApiError(503, "connection_oauth_credential_unavailable", "The OAuth connection is not authorized.");
|
|
335
|
+
if (!credential.expiresAt || Date.parse(credential.expiresAt) > Date.now() + 60_000) {
|
|
336
|
+
return { token: await decryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, credential.accessToken, `oauth-access:${connectionVersionId}`), tokenType: credential.tokenType ?? "Bearer" };
|
|
337
|
+
}
|
|
338
|
+
if (!credential.refreshToken) throw new ApiError(503, "connection_oauth_refresh_required", "The OAuth access token expired without a refresh token.");
|
|
339
|
+
const configuration = oauthConfiguration(credential.oauthConfigurationJson);
|
|
340
|
+
const refreshToken = await decryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, credential.refreshToken, `oauth-refresh:${connectionVersionId}`);
|
|
341
|
+
try {
|
|
342
|
+
const refreshed = tokenFields(await tokenExchange(env, configuration, { grant_type: "refresh_token", refresh_token: refreshToken }));
|
|
343
|
+
const timestamp = now();
|
|
344
|
+
const updated = await env.DB.prepare(`UPDATE connection_credentials SET encrypted_access_token = ?2, encrypted_refresh_token = COALESCE(?3, encrypted_refresh_token), token_type = ?4, granted_scopes_json = CASE WHEN ?5 = '[]' THEN granted_scopes_json ELSE ?5 END, expires_at = ?6, status = 'active', last_refresh_at = ?7, last_error_code = NULL, updated_at = ?7 WHERE id = ?1 AND updated_at = ?8`)
|
|
345
|
+
.bind(
|
|
346
|
+
credential.id,
|
|
347
|
+
await encryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, refreshed.accessToken, `oauth-access:${connectionVersionId}`),
|
|
348
|
+
refreshed.refreshToken ? await encryptCredential(env.CREDENTIAL_ENCRYPTION_KEY, refreshed.refreshToken, `oauth-refresh:${connectionVersionId}`) : null,
|
|
349
|
+
refreshed.tokenType, canonicalJson(refreshed.scopes), refreshed.expiresAt, timestamp, credential.updatedAt,
|
|
350
|
+
).run();
|
|
351
|
+
if (updated.meta.changes !== 1) return resolveOAuthAccessToken(env, connectionVersionId);
|
|
352
|
+
return { token: refreshed.accessToken, tokenType: refreshed.tokenType };
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const current = await env.DB.prepare(`SELECT status, updated_at AS updatedAt FROM connection_credentials WHERE id = ?1 LIMIT 1`)
|
|
355
|
+
.bind(credential.id).first<{ status: string; updatedAt: string }>();
|
|
356
|
+
if (current?.status === "active" && current.updatedAt !== credential.updatedAt) return resolveOAuthAccessToken(env, connectionVersionId);
|
|
357
|
+
await env.DB.prepare(`UPDATE connection_credentials SET status = 'refresh_failed', last_error_code = 'oauth_refresh_failed', updated_at = ?2 WHERE id = ?1 AND updated_at = ?3`)
|
|
358
|
+
.bind(credential.id, now(), credential.updatedAt).run();
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function pruneConnectionOAuthStates(db: D1Database): Promise<number> {
|
|
364
|
+
const cutoff = new Date(Date.now() - 24 * 60 * 60_000).toISOString();
|
|
365
|
+
const result = await db.prepare(`DELETE FROM connection_oauth_states WHERE expires_at <= ?1 OR (consumed_at IS NOT NULL AND consumed_at <= ?1)`)
|
|
366
|
+
.bind(cutoff).run();
|
|
367
|
+
return Number(result.meta.changes ?? 0);
|
|
368
|
+
}
|