@lobu/gateway 2.8.0 → 3.0.6
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/package.json +2 -2
- package/src/__tests__/agent-config-routes.test.ts +254 -0
- package/src/__tests__/agent-history-routes.test.ts +72 -0
- package/src/__tests__/agent-routes.test.ts +68 -0
- package/src/__tests__/agent-schedules-routes.test.ts +59 -0
- package/src/__tests__/agent-settings-store.test.ts +323 -0
- package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
- package/src/__tests__/chat-response-bridge.test.ts +131 -0
- package/src/__tests__/config-memory-plugins.test.ts +92 -0
- package/src/__tests__/config-request-store.test.ts +127 -0
- package/src/__tests__/connection-routes.test.ts +144 -0
- package/src/__tests__/core-services-store-selection.test.ts +92 -0
- package/src/__tests__/docker-deployment.test.ts +1211 -0
- package/src/__tests__/embedded-deployment.test.ts +342 -0
- package/src/__tests__/grant-store.test.ts +148 -0
- package/src/__tests__/http-proxy.test.ts +281 -0
- package/src/__tests__/instruction-service.test.ts +37 -0
- package/src/__tests__/link-buttons.test.ts +112 -0
- package/src/__tests__/lobu.test.ts +32 -0
- package/src/__tests__/mcp-config-service.test.ts +347 -0
- package/src/__tests__/mcp-proxy.test.ts +696 -0
- package/src/__tests__/message-handler-bridge.test.ts +17 -0
- package/src/__tests__/model-selection.test.ts +172 -0
- package/src/__tests__/oauth-templates.test.ts +39 -0
- package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
- package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
- package/src/__tests__/provider-inheritance.test.ts +212 -0
- package/src/__tests__/routes/cli-auth.test.ts +337 -0
- package/src/__tests__/routes/interactions.test.ts +121 -0
- package/src/__tests__/secret-proxy.test.ts +85 -0
- package/src/__tests__/session-manager.test.ts +572 -0
- package/src/__tests__/setup.ts +133 -0
- package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
- package/src/__tests__/slack-routes.test.ts +161 -0
- package/src/__tests__/system-config-resolver.test.ts +75 -0
- package/src/__tests__/system-message-limiter.test.ts +89 -0
- package/src/__tests__/system-skills-service.test.ts +362 -0
- package/src/__tests__/transcription-service.test.ts +222 -0
- package/src/__tests__/utils/rate-limiter.test.ts +102 -0
- package/src/__tests__/worker-connection-manager.test.ts +497 -0
- package/src/__tests__/worker-job-router.test.ts +722 -0
- package/src/api/index.ts +1 -0
- package/src/api/platform.ts +292 -0
- package/src/api/response-renderer.ts +157 -0
- package/src/auth/agent-metadata-store.ts +168 -0
- package/src/auth/api-auth-middleware.ts +69 -0
- package/src/auth/api-key-provider-module.ts +213 -0
- package/src/auth/base-provider-module.ts +201 -0
- package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
- package/src/auth/chatgpt/device-code-client.ts +218 -0
- package/src/auth/chatgpt/index.ts +1 -0
- package/src/auth/claude/oauth-module.ts +280 -0
- package/src/auth/cli/token-service.ts +249 -0
- package/src/auth/external/client.ts +560 -0
- package/src/auth/external/device-code-client.ts +225 -0
- package/src/auth/mcp/config-service.ts +392 -0
- package/src/auth/mcp/proxy.ts +1088 -0
- package/src/auth/mcp/string-substitution.ts +17 -0
- package/src/auth/mcp/tool-cache.ts +90 -0
- package/src/auth/oauth/base-client.ts +267 -0
- package/src/auth/oauth/client.ts +153 -0
- package/src/auth/oauth/credentials.ts +7 -0
- package/src/auth/oauth/providers.ts +69 -0
- package/src/auth/oauth/state-store.ts +150 -0
- package/src/auth/oauth-templates.ts +179 -0
- package/src/auth/provider-catalog.ts +220 -0
- package/src/auth/provider-model-options.ts +41 -0
- package/src/auth/settings/agent-settings-store.ts +565 -0
- package/src/auth/settings/auth-profiles-manager.ts +216 -0
- package/src/auth/settings/index.ts +12 -0
- package/src/auth/settings/model-preference-store.ts +52 -0
- package/src/auth/settings/model-selection.ts +135 -0
- package/src/auth/settings/resolved-settings-view.ts +298 -0
- package/src/auth/settings/template-utils.ts +44 -0
- package/src/auth/settings/token-service.ts +88 -0
- package/src/auth/system-env-store.ts +98 -0
- package/src/auth/user-agents-store.ts +68 -0
- package/src/channels/binding-service.ts +214 -0
- package/src/channels/index.ts +4 -0
- package/src/cli/gateway.ts +1304 -0
- package/src/cli/index.ts +74 -0
- package/src/commands/built-in-commands.ts +80 -0
- package/src/commands/command-dispatcher.ts +94 -0
- package/src/commands/command-reply-adapters.ts +27 -0
- package/src/config/file-loader.ts +618 -0
- package/src/config/index.ts +588 -0
- package/src/config/network-allowlist.ts +71 -0
- package/src/connections/chat-instance-manager.ts +1284 -0
- package/src/connections/chat-response-bridge.ts +618 -0
- package/src/connections/index.ts +7 -0
- package/src/connections/interaction-bridge.ts +831 -0
- package/src/connections/message-handler-bridge.ts +415 -0
- package/src/connections/platform-auth-methods.ts +15 -0
- package/src/connections/types.ts +84 -0
- package/src/gateway/connection-manager.ts +291 -0
- package/src/gateway/index.ts +700 -0
- package/src/gateway/job-router.ts +201 -0
- package/src/gateway-main.ts +200 -0
- package/src/index.ts +41 -0
- package/src/infrastructure/queue/index.ts +12 -0
- package/src/infrastructure/queue/queue-producer.ts +148 -0
- package/src/infrastructure/queue/redis-queue.ts +361 -0
- package/src/infrastructure/queue/types.ts +133 -0
- package/src/infrastructure/redis/system-message-limiter.ts +94 -0
- package/src/interactions/config-request-store.ts +198 -0
- package/src/interactions.ts +363 -0
- package/src/lobu.ts +311 -0
- package/src/metrics/prometheus.ts +159 -0
- package/src/modules/module-system.ts +179 -0
- package/src/orchestration/base-deployment-manager.ts +900 -0
- package/src/orchestration/deployment-utils.ts +98 -0
- package/src/orchestration/impl/docker-deployment.ts +620 -0
- package/src/orchestration/impl/embedded-deployment.ts +268 -0
- package/src/orchestration/impl/index.ts +8 -0
- package/src/orchestration/impl/k8s/deployment.ts +1061 -0
- package/src/orchestration/impl/k8s/helpers.ts +610 -0
- package/src/orchestration/impl/k8s/index.ts +1 -0
- package/src/orchestration/index.ts +333 -0
- package/src/orchestration/message-consumer.ts +584 -0
- package/src/orchestration/scheduled-wakeup.ts +704 -0
- package/src/permissions/approval-policy.ts +36 -0
- package/src/permissions/grant-store.ts +219 -0
- package/src/platform/file-handler.ts +66 -0
- package/src/platform/link-buttons.ts +57 -0
- package/src/platform/renderer-utils.ts +44 -0
- package/src/platform/response-renderer.ts +84 -0
- package/src/platform/unified-thread-consumer.ts +187 -0
- package/src/platform.ts +318 -0
- package/src/proxy/http-proxy.ts +752 -0
- package/src/proxy/proxy-manager.ts +81 -0
- package/src/proxy/secret-proxy.ts +402 -0
- package/src/proxy/token-refresh-job.ts +143 -0
- package/src/routes/internal/audio.ts +141 -0
- package/src/routes/internal/device-auth.ts +566 -0
- package/src/routes/internal/files.ts +226 -0
- package/src/routes/internal/history.ts +69 -0
- package/src/routes/internal/images.ts +127 -0
- package/src/routes/internal/interactions.ts +84 -0
- package/src/routes/internal/middleware.ts +23 -0
- package/src/routes/internal/schedule.ts +226 -0
- package/src/routes/internal/types.ts +22 -0
- package/src/routes/openapi-auto.ts +239 -0
- package/src/routes/public/agent-access.ts +23 -0
- package/src/routes/public/agent-config.ts +675 -0
- package/src/routes/public/agent-history.ts +422 -0
- package/src/routes/public/agent-schedules.ts +296 -0
- package/src/routes/public/agent.ts +1086 -0
- package/src/routes/public/agents.ts +373 -0
- package/src/routes/public/channels.ts +191 -0
- package/src/routes/public/cli-auth.ts +883 -0
- package/src/routes/public/connections.ts +574 -0
- package/src/routes/public/landing.ts +16 -0
- package/src/routes/public/oauth.ts +147 -0
- package/src/routes/public/settings-auth.ts +104 -0
- package/src/routes/public/slack.ts +173 -0
- package/src/routes/shared/agent-ownership.ts +101 -0
- package/src/routes/shared/token-verifier.ts +34 -0
- package/src/services/core-services.ts +1053 -0
- package/src/services/image-generation-service.ts +257 -0
- package/src/services/instruction-service.ts +318 -0
- package/src/services/mcp-registry.ts +94 -0
- package/src/services/platform-helpers.ts +287 -0
- package/src/services/session-manager.ts +262 -0
- package/src/services/settings-resolver.ts +74 -0
- package/src/services/system-config-resolver.ts +90 -0
- package/src/services/system-skills-service.ts +229 -0
- package/src/services/transcription-service.ts +684 -0
- package/src/session.ts +110 -0
- package/src/spaces/index.ts +1 -0
- package/src/spaces/space-resolver.ts +17 -0
- package/src/stores/in-memory-agent-store.ts +403 -0
- package/src/stores/redis-agent-store.ts +279 -0
- package/src/utils/public-url.ts +44 -0
- package/src/utils/rate-limiter.ts +94 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal Audio Routes
|
|
3
|
+
*
|
|
4
|
+
* Worker-facing endpoints for audio generation (TTS).
|
|
5
|
+
* Used by the GenerateAudio custom MCP tool.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createLogger } from "@lobu/core";
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import type { TranscriptionService } from "../../services/transcription-service";
|
|
11
|
+
import { authenticateWorker } from "./middleware";
|
|
12
|
+
import type { WorkerContext } from "./types";
|
|
13
|
+
|
|
14
|
+
const logger = createLogger("internal-audio-routes");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create internal audio routes (Hono)
|
|
18
|
+
*/
|
|
19
|
+
export function createAudioRoutes(
|
|
20
|
+
transcriptionService: TranscriptionService
|
|
21
|
+
): Hono<WorkerContext> {
|
|
22
|
+
const router = new Hono<WorkerContext>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate audio from text (TTS)
|
|
26
|
+
* POST /internal/audio/synthesize
|
|
27
|
+
*
|
|
28
|
+
* Body: {
|
|
29
|
+
* text: string (required) - Text to convert to speech
|
|
30
|
+
* voice?: string - Provider-specific voice ID
|
|
31
|
+
* speed?: number - Speech speed (0.5-2.0)
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Response: Audio file (binary) with Content-Type header
|
|
35
|
+
*/
|
|
36
|
+
router.post("/internal/audio/synthesize", authenticateWorker, async (c) => {
|
|
37
|
+
try {
|
|
38
|
+
const worker = c.get("worker");
|
|
39
|
+
const { text, voice, speed } = await c.req.json();
|
|
40
|
+
|
|
41
|
+
if (!text || typeof text !== "string") {
|
|
42
|
+
return c.json({ error: "text is required and must be a string" }, 400);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (text.length > 4096) {
|
|
46
|
+
return c.json({ error: "text must be 4096 characters or less" }, 400);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const agentId = worker.agentId;
|
|
50
|
+
if (!agentId) {
|
|
51
|
+
return c.json({ error: "Missing agentId in worker context" }, 400);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
logger.info("Synthesizing audio", {
|
|
55
|
+
agentId,
|
|
56
|
+
textLength: text.length,
|
|
57
|
+
voice,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = await transcriptionService.synthesize(text, agentId, {
|
|
61
|
+
voice,
|
|
62
|
+
speed,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if ("error" in result) {
|
|
66
|
+
logger.warn("Audio synthesis failed", { error: result.error });
|
|
67
|
+
return c.json(
|
|
68
|
+
{
|
|
69
|
+
error: result.error,
|
|
70
|
+
availableProviders: result.availableProviders,
|
|
71
|
+
},
|
|
72
|
+
400
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Return audio as binary response
|
|
77
|
+
return new Response(result.audioBuffer, {
|
|
78
|
+
headers: {
|
|
79
|
+
"Content-Type": result.mimeType,
|
|
80
|
+
"Content-Length": result.audioBuffer.length.toString(),
|
|
81
|
+
"X-Audio-Provider": result.provider,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
logger.error("Audio synthesis error", { error });
|
|
86
|
+
return c.json(
|
|
87
|
+
{
|
|
88
|
+
error:
|
|
89
|
+
error instanceof Error
|
|
90
|
+
? error.message
|
|
91
|
+
: "Failed to synthesize audio",
|
|
92
|
+
},
|
|
93
|
+
500
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check audio capabilities for current agent
|
|
100
|
+
* GET /internal/audio/capabilities
|
|
101
|
+
*
|
|
102
|
+
* Response: {
|
|
103
|
+
* available: boolean,
|
|
104
|
+
* provider?: string,
|
|
105
|
+
* features: { transcription: boolean, synthesis: boolean }
|
|
106
|
+
* }
|
|
107
|
+
*/
|
|
108
|
+
router.get("/internal/audio/capabilities", authenticateWorker, async (c) => {
|
|
109
|
+
try {
|
|
110
|
+
const worker = c.get("worker");
|
|
111
|
+
const agentId = worker.agentId;
|
|
112
|
+
|
|
113
|
+
if (!agentId) {
|
|
114
|
+
return c.json({ error: "Missing agentId in worker context" }, 400);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const config = await transcriptionService.getConfig(agentId);
|
|
118
|
+
|
|
119
|
+
if (!config) {
|
|
120
|
+
return c.json({
|
|
121
|
+
available: false,
|
|
122
|
+
features: { transcription: false, synthesis: false },
|
|
123
|
+
providers: transcriptionService.getProviderInfo(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return c.json({
|
|
128
|
+
available: true,
|
|
129
|
+
provider: config.provider,
|
|
130
|
+
features: { transcription: true, synthesis: true },
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.error("Capabilities check error", { error });
|
|
134
|
+
return c.json({ error: "Failed to check capabilities" }, 500);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
logger.debug("Internal audio routes registered");
|
|
139
|
+
|
|
140
|
+
return router;
|
|
141
|
+
}
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { createLogger, decrypt, encrypt } from "@lobu/core";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import type Redis from "ioredis";
|
|
4
|
+
import { GenericDeviceCodeClient } from "../../auth/external/device-code-client";
|
|
5
|
+
import type { McpConfigService } from "../../auth/mcp/config-service";
|
|
6
|
+
import { authenticateWorker } from "./middleware";
|
|
7
|
+
import type { WorkerContext } from "./types";
|
|
8
|
+
|
|
9
|
+
const logger = createLogger("device-auth");
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MCP_SCOPE = "mcp:read mcp:write profile:read";
|
|
12
|
+
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
13
|
+
|
|
14
|
+
interface StoredCredential {
|
|
15
|
+
accessToken: string;
|
|
16
|
+
refreshToken?: string;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
clientId: string;
|
|
19
|
+
clientSecret?: string;
|
|
20
|
+
tokenUrl: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface StoredDeviceAuth {
|
|
24
|
+
deviceCode: string;
|
|
25
|
+
userCode?: string;
|
|
26
|
+
clientId: string;
|
|
27
|
+
clientSecret?: string;
|
|
28
|
+
interval: number;
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
tokenUrl: string;
|
|
31
|
+
issuer: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface StoredClient {
|
|
35
|
+
clientId: string;
|
|
36
|
+
clientSecret?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DeviceAuthConfig {
|
|
40
|
+
redis: Redis;
|
|
41
|
+
mcpConfigService: McpConfigService;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function credentialKey(agentId: string, userId: string, mcpId: string): string {
|
|
45
|
+
return `auth:credential:${agentId}:${userId}:${mcpId}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function deviceAuthKey(agentId: string, userId: string, mcpId: string): string {
|
|
49
|
+
return `device-auth:${agentId}:${userId}:${mcpId}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clientCacheKey(mcpId: string): string {
|
|
53
|
+
return `device-auth:client:${mcpId}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function refreshLockKey(
|
|
57
|
+
agentId: string,
|
|
58
|
+
userId: string,
|
|
59
|
+
mcpId: string
|
|
60
|
+
): string {
|
|
61
|
+
return `auth:refresh-lock:${agentId}:${userId}:${mcpId}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getStoredCredential(
|
|
65
|
+
redis: Redis,
|
|
66
|
+
agentId: string,
|
|
67
|
+
userId: string,
|
|
68
|
+
mcpId: string
|
|
69
|
+
): Promise<StoredCredential | null> {
|
|
70
|
+
const raw = await redis.get(credentialKey(agentId, userId, mcpId));
|
|
71
|
+
if (!raw) return null;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(decrypt(raw)) as StoredCredential;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function storeCredential(
|
|
80
|
+
redis: Redis,
|
|
81
|
+
agentId: string,
|
|
82
|
+
userId: string,
|
|
83
|
+
mcpId: string,
|
|
84
|
+
credential: StoredCredential
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const encrypted = encrypt(JSON.stringify(credential));
|
|
87
|
+
// Store with 90-day TTL (tokens can be refreshed before expiry)
|
|
88
|
+
await redis.set(
|
|
89
|
+
credentialKey(agentId, userId, mcpId),
|
|
90
|
+
encrypted,
|
|
91
|
+
"EX",
|
|
92
|
+
90 * 24 * 60 * 60
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function refreshCredential(
|
|
97
|
+
redis: Redis,
|
|
98
|
+
agentId: string,
|
|
99
|
+
userId: string,
|
|
100
|
+
mcpId: string,
|
|
101
|
+
credential: StoredCredential
|
|
102
|
+
): Promise<StoredCredential | null> {
|
|
103
|
+
if (!credential.refreshToken) return null;
|
|
104
|
+
|
|
105
|
+
const lockKey = refreshLockKey(agentId, userId, mcpId);
|
|
106
|
+
const acquired = await redis.set(lockKey, "1", "EX", 30, "NX");
|
|
107
|
+
|
|
108
|
+
if (!acquired) {
|
|
109
|
+
// Another request is refreshing — wait briefly and re-read
|
|
110
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
111
|
+
return getStoredCredential(redis, agentId, userId, mcpId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const body: Record<string, string> = {
|
|
116
|
+
grant_type: "refresh_token",
|
|
117
|
+
client_id: credential.clientId,
|
|
118
|
+
refresh_token: credential.refreshToken,
|
|
119
|
+
};
|
|
120
|
+
if (credential.clientSecret) {
|
|
121
|
+
body.client_secret = credential.clientSecret;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const response = await fetch(credential.tokenUrl, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
Accept: "application/json",
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify(body),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
logger.error("Token refresh failed", {
|
|
135
|
+
status: response.status,
|
|
136
|
+
agentId,
|
|
137
|
+
userId,
|
|
138
|
+
mcpId,
|
|
139
|
+
});
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
144
|
+
if (typeof data.access_token !== "string") return null;
|
|
145
|
+
|
|
146
|
+
const refreshed: StoredCredential = {
|
|
147
|
+
accessToken: data.access_token,
|
|
148
|
+
refreshToken:
|
|
149
|
+
typeof data.refresh_token === "string"
|
|
150
|
+
? data.refresh_token
|
|
151
|
+
: credential.refreshToken,
|
|
152
|
+
expiresAt:
|
|
153
|
+
typeof data.expires_in === "number"
|
|
154
|
+
? Date.now() + data.expires_in * 1000
|
|
155
|
+
: Date.now() + 3_600_000,
|
|
156
|
+
clientId: credential.clientId,
|
|
157
|
+
clientSecret: credential.clientSecret,
|
|
158
|
+
tokenUrl: credential.tokenUrl,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
await storeCredential(redis, agentId, userId, mcpId, refreshed);
|
|
162
|
+
logger.info("Token refreshed", { agentId, userId, mcpId });
|
|
163
|
+
return refreshed;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
logger.error("Token refresh error", { error, agentId, userId, mcpId });
|
|
166
|
+
return null;
|
|
167
|
+
} finally {
|
|
168
|
+
await redis.del(lockKey).catch(() => undefined);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Try to complete a pending device-code auth flow.
|
|
174
|
+
* Called by the MCP proxy's resolveCredentialToken when no stored credential
|
|
175
|
+
* exists but a device-auth flow may have been started earlier.
|
|
176
|
+
* Returns the access token on success, null if pending or no flow in progress.
|
|
177
|
+
*/
|
|
178
|
+
export async function tryCompletePendingDeviceAuth(
|
|
179
|
+
redis: Redis,
|
|
180
|
+
agentId: string,
|
|
181
|
+
userId: string,
|
|
182
|
+
mcpId: string
|
|
183
|
+
): Promise<string | null> {
|
|
184
|
+
const raw = await redis.get(deviceAuthKey(agentId, userId, mcpId));
|
|
185
|
+
if (!raw) return null;
|
|
186
|
+
|
|
187
|
+
let deviceState: StoredDeviceAuth;
|
|
188
|
+
try {
|
|
189
|
+
deviceState = JSON.parse(raw) as StoredDeviceAuth;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (Date.now() > deviceState.expiresAt) {
|
|
195
|
+
await redis.del(deviceAuthKey(agentId, userId, mcpId));
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const deviceCodeClient = new GenericDeviceCodeClient({
|
|
201
|
+
clientId: deviceState.clientId,
|
|
202
|
+
clientSecret: deviceState.clientSecret,
|
|
203
|
+
tokenUrl: deviceState.tokenUrl,
|
|
204
|
+
deviceAuthorizationUrl: `${deviceState.issuer}/oauth/device_authorization`,
|
|
205
|
+
scope: DEFAULT_MCP_SCOPE,
|
|
206
|
+
tokenEndpointAuthMethod: deviceState.clientSecret
|
|
207
|
+
? "client_secret_post"
|
|
208
|
+
: "none",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const pollResult = await deviceCodeClient.pollForToken(
|
|
212
|
+
deviceState.deviceCode,
|
|
213
|
+
deviceState.interval
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (pollResult.status === "pending") {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (pollResult.status === "error") {
|
|
221
|
+
await redis.del(deviceAuthKey(agentId, userId, mcpId));
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Success — store credential and clean up
|
|
226
|
+
const { credentials } = pollResult;
|
|
227
|
+
const storedCred: StoredCredential = {
|
|
228
|
+
accessToken: credentials.accessToken,
|
|
229
|
+
refreshToken: credentials.refreshToken,
|
|
230
|
+
expiresAt: credentials.expiresAt,
|
|
231
|
+
clientId: deviceState.clientId,
|
|
232
|
+
clientSecret: deviceState.clientSecret,
|
|
233
|
+
tokenUrl: deviceState.tokenUrl,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
await storeCredential(redis, agentId, userId, mcpId, storedCred);
|
|
237
|
+
await redis.del(deviceAuthKey(agentId, userId, mcpId));
|
|
238
|
+
|
|
239
|
+
logger.info("Device auth auto-completed by proxy", {
|
|
240
|
+
mcpId,
|
|
241
|
+
agentId,
|
|
242
|
+
userId,
|
|
243
|
+
});
|
|
244
|
+
return credentials.accessToken;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
logger.warn("Auto-complete device auth failed", {
|
|
247
|
+
mcpId,
|
|
248
|
+
error: error instanceof Error ? error.message : String(error),
|
|
249
|
+
});
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function deriveOAuthBaseUrl(upstreamUrl: string): string {
|
|
255
|
+
const url = new URL(upstreamUrl);
|
|
256
|
+
url.pathname = "/";
|
|
257
|
+
url.search = "";
|
|
258
|
+
url.hash = "";
|
|
259
|
+
return url.toString().replace(/\/$/, "");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Start device-code auth flow for a given MCP server.
|
|
264
|
+
* Reusable by the MCP proxy to auto-initiate auth on "unauthorized" errors.
|
|
265
|
+
*/
|
|
266
|
+
export async function startDeviceAuth(
|
|
267
|
+
redis: Redis,
|
|
268
|
+
mcpConfigService: {
|
|
269
|
+
getHttpServer: (
|
|
270
|
+
id: string,
|
|
271
|
+
agentId?: string
|
|
272
|
+
) => Promise<{ upstreamUrl: string } | undefined>;
|
|
273
|
+
},
|
|
274
|
+
mcpId: string,
|
|
275
|
+
agentId: string,
|
|
276
|
+
userId: string
|
|
277
|
+
): Promise<{
|
|
278
|
+
userCode: string;
|
|
279
|
+
verificationUri: string;
|
|
280
|
+
verificationUriComplete?: string;
|
|
281
|
+
expiresIn: number;
|
|
282
|
+
} | null> {
|
|
283
|
+
// Reuse existing pending device auth flow if not expired
|
|
284
|
+
const existingRaw = await redis.get(deviceAuthKey(agentId, userId, mcpId));
|
|
285
|
+
if (existingRaw) {
|
|
286
|
+
try {
|
|
287
|
+
const existing = JSON.parse(existingRaw) as StoredDeviceAuth;
|
|
288
|
+
if (existing.expiresAt > Date.now()) {
|
|
289
|
+
const httpServer = await mcpConfigService.getHttpServer(mcpId, agentId);
|
|
290
|
+
const issuer = httpServer
|
|
291
|
+
? deriveOAuthBaseUrl(httpServer.upstreamUrl)
|
|
292
|
+
: existing.issuer;
|
|
293
|
+
const verificationUri = `${issuer}/oauth/device`;
|
|
294
|
+
logger.info("Reusing existing pending device auth", {
|
|
295
|
+
mcpId,
|
|
296
|
+
agentId,
|
|
297
|
+
userId,
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
userCode: existing.userCode || "",
|
|
301
|
+
verificationUri,
|
|
302
|
+
verificationUriComplete: existing.userCode
|
|
303
|
+
? `${verificationUri}?user_code=${existing.userCode}`
|
|
304
|
+
: verificationUri,
|
|
305
|
+
expiresIn: Math.floor((existing.expiresAt - Date.now()) / 1000),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
// Corrupted state — fall through to start new flow
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const httpServer = await mcpConfigService.getHttpServer(mcpId, agentId);
|
|
314
|
+
if (!httpServer) {
|
|
315
|
+
logger.warn("startDeviceAuth: httpServer not found", { mcpId, agentId });
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const issuer = deriveOAuthBaseUrl(httpServer.upstreamUrl);
|
|
320
|
+
|
|
321
|
+
// Check cached client registration
|
|
322
|
+
let client: StoredClient | null = null;
|
|
323
|
+
const cachedClient = await redis.get(clientCacheKey(mcpId));
|
|
324
|
+
if (cachedClient) {
|
|
325
|
+
try {
|
|
326
|
+
client = JSON.parse(cachedClient) as StoredClient;
|
|
327
|
+
} catch {
|
|
328
|
+
client = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Register a new client if needed
|
|
333
|
+
if (!client) {
|
|
334
|
+
const regResponse = await fetch(`${issuer}/oauth/register`, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: { "Content-Type": "application/json" },
|
|
337
|
+
body: JSON.stringify({
|
|
338
|
+
grant_types: [DEVICE_CODE_GRANT_TYPE, "refresh_token"],
|
|
339
|
+
token_endpoint_auth_method: "none",
|
|
340
|
+
client_name: "Lobu Gateway Device Auth",
|
|
341
|
+
scope: DEFAULT_MCP_SCOPE,
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (!regResponse.ok) return null;
|
|
346
|
+
|
|
347
|
+
const registration = (await regResponse.json()) as {
|
|
348
|
+
client_id: string;
|
|
349
|
+
client_secret?: string;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
client = {
|
|
353
|
+
clientId: registration.client_id,
|
|
354
|
+
clientSecret: registration.client_secret,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await redis.set(clientCacheKey(mcpId), JSON.stringify(client));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const deviceCodeClient = new GenericDeviceCodeClient({
|
|
361
|
+
clientId: client.clientId,
|
|
362
|
+
clientSecret: client.clientSecret,
|
|
363
|
+
tokenUrl: `${issuer}/oauth/token`,
|
|
364
|
+
deviceAuthorizationUrl: `${issuer}/oauth/device_authorization`,
|
|
365
|
+
scope: DEFAULT_MCP_SCOPE,
|
|
366
|
+
tokenEndpointAuthMethod: client.clientSecret
|
|
367
|
+
? "client_secret_post"
|
|
368
|
+
: "none",
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const started = await deviceCodeClient.requestDeviceCode();
|
|
372
|
+
|
|
373
|
+
const deviceState: StoredDeviceAuth = {
|
|
374
|
+
deviceCode: started.deviceAuthId,
|
|
375
|
+
userCode: started.userCode,
|
|
376
|
+
clientId: client.clientId,
|
|
377
|
+
clientSecret: client.clientSecret,
|
|
378
|
+
interval: started.interval,
|
|
379
|
+
expiresAt: Date.now() + started.expiresIn * 1000,
|
|
380
|
+
tokenUrl: `${issuer}/oauth/token`,
|
|
381
|
+
issuer,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
await redis.set(
|
|
385
|
+
deviceAuthKey(agentId, userId, mcpId),
|
|
386
|
+
JSON.stringify(deviceState),
|
|
387
|
+
"EX",
|
|
388
|
+
started.expiresIn
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
logger.info("Device auth started (auto)", { mcpId, agentId, userId });
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
userCode: started.userCode,
|
|
395
|
+
verificationUri: started.verificationUri,
|
|
396
|
+
verificationUriComplete: started.verificationUriComplete,
|
|
397
|
+
expiresIn: started.expiresIn,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function createDeviceAuthRoutes(
|
|
402
|
+
config: DeviceAuthConfig
|
|
403
|
+
): Hono<WorkerContext> {
|
|
404
|
+
const { redis, mcpConfigService } = config;
|
|
405
|
+
const router = new Hono<WorkerContext>();
|
|
406
|
+
|
|
407
|
+
// POST /internal/device-auth/start
|
|
408
|
+
router.post("/internal/device-auth/start", authenticateWorker, async (c) => {
|
|
409
|
+
const body = await c.req.json<{ mcpId: string }>();
|
|
410
|
+
const mcpId = body?.mcpId;
|
|
411
|
+
if (!mcpId) {
|
|
412
|
+
return c.json({ error: "Missing required field: mcpId" }, 400);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const worker = c.get("worker");
|
|
416
|
+
const agentId = worker.agentId || worker.userId;
|
|
417
|
+
const userId = worker.userId;
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const result = await startDeviceAuth(
|
|
421
|
+
redis,
|
|
422
|
+
mcpConfigService,
|
|
423
|
+
mcpId,
|
|
424
|
+
agentId,
|
|
425
|
+
userId
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
if (!result) {
|
|
429
|
+
return c.json(
|
|
430
|
+
{
|
|
431
|
+
error: `MCP server '${mcpId}' not found or client registration failed`,
|
|
432
|
+
},
|
|
433
|
+
404
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return c.json(result);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
logger.error("Failed to start device auth", {
|
|
440
|
+
mcpId,
|
|
441
|
+
error: error instanceof Error ? error.message : String(error),
|
|
442
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
443
|
+
});
|
|
444
|
+
return c.json({ error: "Failed to start device authentication" }, 500);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// POST /internal/device-auth/poll
|
|
449
|
+
router.post("/internal/device-auth/poll", authenticateWorker, async (c) => {
|
|
450
|
+
const body = await c.req.json<{ mcpId: string }>();
|
|
451
|
+
const mcpId = body?.mcpId;
|
|
452
|
+
if (!mcpId) {
|
|
453
|
+
return c.json({ error: "Missing required field: mcpId" }, 400);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const worker = c.get("worker");
|
|
457
|
+
const agentId = worker.agentId || worker.userId;
|
|
458
|
+
const userId = worker.userId;
|
|
459
|
+
|
|
460
|
+
const raw = await redis.get(deviceAuthKey(agentId, userId, mcpId));
|
|
461
|
+
if (!raw) {
|
|
462
|
+
return c.json(
|
|
463
|
+
{
|
|
464
|
+
status: "error",
|
|
465
|
+
message: "No device auth in progress. Call start first.",
|
|
466
|
+
},
|
|
467
|
+
400
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const deviceState = JSON.parse(raw) as StoredDeviceAuth;
|
|
472
|
+
|
|
473
|
+
if (Date.now() > deviceState.expiresAt) {
|
|
474
|
+
await redis.del(deviceAuthKey(agentId, userId, mcpId));
|
|
475
|
+
return c.json(
|
|
476
|
+
{ status: "error", message: "Device code expired. Start again." },
|
|
477
|
+
400
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
const deviceCodeClient = new GenericDeviceCodeClient({
|
|
483
|
+
clientId: deviceState.clientId,
|
|
484
|
+
clientSecret: deviceState.clientSecret,
|
|
485
|
+
tokenUrl: deviceState.tokenUrl,
|
|
486
|
+
deviceAuthorizationUrl: `${deviceState.issuer}/oauth/device_authorization`,
|
|
487
|
+
scope: DEFAULT_MCP_SCOPE,
|
|
488
|
+
tokenEndpointAuthMethod: deviceState.clientSecret
|
|
489
|
+
? "client_secret_post"
|
|
490
|
+
: "none",
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const pollResult = await deviceCodeClient.pollForToken(
|
|
494
|
+
deviceState.deviceCode,
|
|
495
|
+
deviceState.interval
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
if (pollResult.status === "pending") {
|
|
499
|
+
// Update interval if slow_down was received
|
|
500
|
+
if (
|
|
501
|
+
pollResult.interval &&
|
|
502
|
+
pollResult.interval !== deviceState.interval
|
|
503
|
+
) {
|
|
504
|
+
deviceState.interval = pollResult.interval;
|
|
505
|
+
const ttl = Math.max(
|
|
506
|
+
Math.floor((deviceState.expiresAt - Date.now()) / 1000),
|
|
507
|
+
10
|
|
508
|
+
);
|
|
509
|
+
await redis.set(
|
|
510
|
+
deviceAuthKey(agentId, userId, mcpId),
|
|
511
|
+
JSON.stringify(deviceState),
|
|
512
|
+
"EX",
|
|
513
|
+
ttl
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
return c.json({ status: "pending" });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (pollResult.status === "error") {
|
|
520
|
+
await redis.del(deviceAuthKey(agentId, userId, mcpId));
|
|
521
|
+
return c.json({ status: "error", message: pollResult.error });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Success — store credential and clean up device auth state
|
|
525
|
+
const { credentials } = pollResult;
|
|
526
|
+
const storedCred: StoredCredential = {
|
|
527
|
+
accessToken: credentials.accessToken,
|
|
528
|
+
refreshToken: credentials.refreshToken,
|
|
529
|
+
expiresAt: credentials.expiresAt,
|
|
530
|
+
clientId: deviceState.clientId,
|
|
531
|
+
clientSecret: deviceState.clientSecret,
|
|
532
|
+
tokenUrl: deviceState.tokenUrl,
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
await storeCredential(redis, agentId, userId, mcpId, storedCred);
|
|
536
|
+
await redis.del(deviceAuthKey(agentId, userId, mcpId));
|
|
537
|
+
|
|
538
|
+
logger.info("Device auth completed", { mcpId, agentId, userId });
|
|
539
|
+
return c.json({ status: "complete" });
|
|
540
|
+
} catch (error) {
|
|
541
|
+
logger.error("Failed to poll device auth", { mcpId, error });
|
|
542
|
+
return c.json(
|
|
543
|
+
{ status: "error", message: "Failed to poll device auth" },
|
|
544
|
+
500
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// GET /internal/device-auth/status?mcpId=owletto
|
|
550
|
+
router.get("/internal/device-auth/status", authenticateWorker, async (c) => {
|
|
551
|
+
const mcpId = c.req.query("mcpId");
|
|
552
|
+
if (!mcpId) {
|
|
553
|
+
return c.json({ error: "Missing required query param: mcpId" }, 400);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const worker = c.get("worker");
|
|
557
|
+
const agentId = worker.agentId || worker.userId;
|
|
558
|
+
const userId = worker.userId;
|
|
559
|
+
|
|
560
|
+
const credential = await getStoredCredential(redis, agentId, userId, mcpId);
|
|
561
|
+
return c.json({ authenticated: !!credential });
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
logger.debug("Device auth routes registered");
|
|
565
|
+
return router;
|
|
566
|
+
}
|