diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index d5aebaf2be..0f425a9473 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -111,10 +111,13 @@ export type DenOrgLlmProviderModel = { export type DenOrgLlmProvider = { id: string; source: "models_dev" | "custom" | "openwork"; + credentialKind: "api_key" | "opencode_oauth"; providerId: string; name: string; providerConfig: Record; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; models: DenOrgLlmProviderModel[]; createdAt: string | null; updatedAt: string | null; @@ -122,6 +125,7 @@ export type DenOrgLlmProvider = { export type DenOrgLlmProviderConnection = DenOrgLlmProvider & { apiKey: string | null; + opencodeAuth: string | null; }; export type DenPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom"; @@ -897,10 +901,13 @@ function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null { return { id: value.id, source: value.source, + credentialKind: value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key", providerId: value.providerId, name: value.name, providerConfig: isRecord(value.providerConfig) ? value.providerConfig : {}, hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, models: Array.isArray(value.models) ? value.models.flatMap((model) => { const parsed = parseDenOrgLlmProviderModel(model); @@ -936,6 +943,7 @@ function getDenOrgLlmProviderConnection(payload: unknown): DenOrgLlmProviderConn return { ...provider, apiKey: typeof payload.llmProvider.apiKey === "string" ? payload.llmProvider.apiKey : null, + opencodeAuth: typeof payload.llmProvider.opencodeAuth === "string" ? payload.llmProvider.opencodeAuth : null, }; } diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts index b0550da8f8..192c403d36 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -163,8 +163,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) a.length === b.length && a.every((value, index) => value === b[index]); const getCloudManagedProviderId = ( - provider: Pick, - ) => provider.source === "openwork" ? "openwork" : provider.id.trim(); + provider: Pick, + ) => { + if (provider.source === "openwork") return "openwork"; + if (provider.credentialKind === "opencode_oauth") return provider.providerId.trim(); + return provider.id.trim(); + }; const getProviderAuthWorkerType = (): "local" | "remote" => options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; @@ -1327,14 +1331,45 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const existingImported = state.importedCloudProviders[cloudProviderId] ?? null; const localProviderId = getCloudManagedProviderId(provider); const apiKey = provider.apiKey?.trim() ?? ""; + const opencodeAuth = provider.opencodeAuth?.trim() ?? ""; const env = getCloudProviderEnv(provider.providerConfig); - if (!apiKey && env.length > 0) { + if (provider.credentialKind === "opencode_oauth" && !opencodeAuth) { + throw new Error(`${provider.name} does not have a stored OpenCode OAuth credential yet.`); + } + if (provider.credentialKind === "api_key" && !apiKey && env.length > 0) { throw new Error(`${provider.name} does not have a stored organization credential yet.`); } await assertCloudProviderImportSafe(provider); - if (apiKey) { + if (provider.credentialKind === "opencode_oauth" && opencodeAuth) { + let parsedAuth: unknown; + try { + parsedAuth = JSON.parse(opencodeAuth); + } catch { + throw new Error(`${provider.name} has invalid OpenCode OAuth JSON.`); + } + if (!parsedAuth || typeof parsedAuth !== "object" || Array.isArray(parsedAuth)) { + throw new Error(`${provider.name} OpenCode OAuth auth must be a JSON object.`); + } + const authRecord = parsedAuth as Record; + if (authRecord.type !== "oauth") { + throw new Error(`${provider.name} OpenCode OAuth auth must include type "oauth".`); + } + if (typeof authRecord.access !== "string" || !authRecord.access.trim()) { + throw new Error(`${provider.name} OpenCode OAuth auth must include an access token.`); + } + if (typeof authRecord.refresh !== "string" || !authRecord.refresh.trim()) { + throw new Error(`${provider.name} OpenCode OAuth auth must include a refresh token.`); + } + if (typeof authRecord.expires !== "number" || !Number.isFinite(authRecord.expires) || authRecord.expires < 0) { + throw new Error(`${provider.name} OpenCode OAuth auth must include a non-negative numeric expires value.`); + } + await c.auth.set({ + providerID: localProviderId, + auth: parsedAuth as Parameters[0]["auth"], + }); + } else if (apiKey) { await c.auth.set({ providerID: localProviderId, auth: { type: "api", key: apiKey }, @@ -1380,6 +1415,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) .filter((id) => id !== localProviderId && id !== existingImported?.providerId); options.setDisabledProviders(nextDisabledProviders); options.markOpencodeConfigReloadRequired(); + await refreshProviders({ dispose: true }).catch(() => null); refreshSnapshot(); emitChange(); return `${t("status.connected")} ${provider.name}`; diff --git a/ee/apps/den-api/src/routes/org/llm-providers.ts b/ee/apps/den-api/src/routes/org/llm-providers.ts index 5cc66da166..e3c4438cca 100644 --- a/ee/apps/den-api/src/routes/org/llm-providers.ts +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -73,10 +73,12 @@ const customProviderSchema = z.object({ const llmProviderWriteSchema = z.object({ name: z.string().trim().min(1).max(255), source: z.enum(["models_dev", "custom"]), + credentialKind: z.enum(["api_key", "opencode_oauth"]).optional().default("api_key"), providerId: z.string().trim().min(1).max(255).optional(), modelIds: z.array(z.string().trim().min(1).max(255)).min(1).optional(), customConfigText: z.string().trim().min(1).optional(), apiKey: z.string().trim().max(65535).optional(), + opencodeAuth: z.string().trim().max(65535).optional(), memberIds: z.array(denTypeIdSchema("member")).max(500).optional().default([]), teamIds: z.array(denTypeIdSchema("team")).max(500).optional().default([]), }).superRefine((value, ctx) => { @@ -105,6 +107,14 @@ const llmProviderWriteSchema = z.object({ message: "Paste a custom provider config.", }) } + + if (value.credentialKind === "opencode_oauth" && value.source === "models_dev" && value.providerId?.trim().toLowerCase() !== "openai") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentialKind"], + message: "OpenCode OAuth credentials can only be used with the OpenAI catalog provider.", + }) + } }) const providerCatalogListResponseSchema = z.object({ @@ -145,6 +155,51 @@ function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin") } +export function getCredentialFlags(provider: Pick) { + const hasApiKey = Boolean(provider.apiKey && provider.apiKey.trim().length > 0) + const hasOpencodeAuth = Boolean(provider.opencodeAuth && provider.opencodeAuth.trim().length > 0) + return { + hasApiKey, + hasOpencodeAuth, + hasCredential: provider.credentialKind === "opencode_oauth" ? hasOpencodeAuth : hasApiKey, + } +} + +export function redactLlmProviderCredentials(provider: T): Omit & { apiKey: undefined; opencodeAuth: undefined } { + return { + ...provider, + apiKey: undefined, + opencodeAuth: undefined, + } +} + +export function canImportLlmProviderCredential(payload: { currentMember: { isOwner: boolean; role: string } }) { + return isOrganizationAdmin(payload) +} + +function normalizeOpencodeAuth(value: string | undefined) { + const trimmed = value?.trim() ?? "" + if (!trimmed) return null + try { + const parsed = JSON.parse(trimmed) as unknown + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || (parsed as Record).type !== "oauth") { + throw new Error("invalid auth") + } + return JSON.stringify(parsed) + } catch { + throw createFailure(400, "invalid_opencode_auth", "OpenCode OAuth credential must be valid OAuth auth JSON.") + } +} + +function buildLlmProviderCredentialPayload(provider: LlmProviderRow) { + return { + ...redactLlmProviderCredentials(provider), + ...getCredentialFlags(provider), + apiKey: provider.credentialKind === "api_key" ? provider.apiKey : undefined, + opencodeAuth: provider.credentialKind === "opencode_oauth" ? provider.opencodeAuth : undefined, + } +} + function canManageLlmProvider( payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, provider: LlmProviderRow, @@ -274,6 +329,10 @@ async function resolveTeamIds(input: { } async function normalizeLlmProviderInput(input: z.infer) { + const credentialKind = input.credentialKind + const apiKey = credentialKind === "api_key" ? input.apiKey?.trim() || null : null + const opencodeAuth = credentialKind === "opencode_oauth" ? normalizeOpencodeAuth(input.opencodeAuth) : null + if (input.source === "models_dev") { const provider = await getModelsDevProvider(input.providerId ?? "") if (!provider) { @@ -290,10 +349,9 @@ async function normalizeLlmProviderInput(input: z.infer ({ ...provider, - hasApiKey: Boolean(provider.apiKey && provider.apiKey.trim().length > 0), + ...getCredentialFlags(provider), models: (modelsByProviderId.get(provider.id) ?? []) .map((model) => ({ id: model.modelId, @@ -607,8 +668,7 @@ export function registerOrgLlmProviderRoutes ({ - ...provider, - apiKey: undefined, + ...redactLlmProviderCredentials(provider), canManage: canManageLlmProvider(payload, provider), })), }) @@ -678,6 +738,69 @@ export function registerOrgLlmProviderRoutes ({ + id: model.modelId, + name: model.name, + config: model.modelConfig, + createdAt: model.createdAt, + })) + .sort((left, right) => left.name.localeCompare(right.name)), + }, + }) + }, + ) + + app.get( + "/v1/llm-providers/:llmProviderId/import-credential", + describeRoute({ + tags: ["LLM Providers"], + summary: "Import LLM provider credential", + description: "Returns credential material for an organization-managed LLM provider to privileged callers only.", + responses: { + 200: jsonResponse("Provider credential returned successfully.", llmProviderResponseSchema), + 400: jsonResponse("The provider path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to import an organization LLM provider credential.", unauthorizedSchema), + 403: jsonResponse("Only members who can manage this provider can import its credential.", forbiddenSchema), + 404: jsonResponse("The provider could not be found.", notFoundSchema), + }, + }), + requireUserMiddleware, + paramValidator(orgLlmProviderParamsSchema), + resolveOrganizationContextMiddleware, + async (c) => { + const payload = c.get("organizationContext") + const params = c.req.valid("param") + + let llmProviderId: LlmProviderId + try { + llmProviderId = parseLlmProviderId(params.llmProviderId) + } catch { + return c.json({ error: "llm_provider_not_found" }, 404) + } + + const providerRows = await db + .select() + .from(LlmProviderTable) + .where(and(eq(LlmProviderTable.id, llmProviderId), eq(LlmProviderTable.organizationId, payload.organization.id))) + .limit(1) + + const provider = providerRows[0] + if (!provider) return c.json({ error: "llm_provider_not_found" }, 404) + if (!canImportLlmProviderCredential(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can import provider credentials." }, 403) + } + + const models = await db + .select() + .from(LlmProviderModelTable) + .where(eq(LlmProviderModelTable.llmProviderId, llmProviderId)) + + return c.json({ + llmProvider: { + ...buildLlmProviderCredentialPayload(provider), models: models .map((model) => ({ id: model.modelId, @@ -732,10 +855,12 @@ export function registerOrgLlmProviderRoutes { + seedRequiredEnv() + llmProviderModule = await import("../src/routes/org/llm-providers.js") +}) + +test("generic provider payload redaction removes API key and OAuth auth material", () => { + const redacted = llmProviderModule.redactLlmProviderCredentials({ + id: "llmProvider_secret_123", + apiKey: "plain-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + }) + + expect(redacted).toEqual({ + id: "llmProvider_secret_123", + apiKey: undefined, + opencodeAuth: undefined, + }) +}) + +test("credential flags expose presence only, never credential values", () => { + expect(llmProviderModule.getCredentialFlags({ + credentialKind: "opencode_oauth", + apiKey: "plain-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + })).toEqual({ hasApiKey: true, hasOpencodeAuth: true, hasCredential: true }) +}) + +test("credential import permission gate requires organization admin role", () => { + const owner = { currentMember: { isOwner: true, role: "member" } } + const admin = { currentMember: { isOwner: false, role: "admin" } } + const creatorOnly = { currentMember: { isOwner: false, role: "member" } } + + expect(llmProviderModule.canImportLlmProviderCredential(owner)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(admin)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(creatorOnly)).toBe(false) +}) + +test("purpose-specific import endpoint requires authentication", async () => { + const app = new Hono() + llmProviderModule.registerOrgLlmProviderRoutes(app) + + const response = await app.request("http://den.local/v1/llm-providers/llmProvider_secret_123/import-credential", { + method: "GET", + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) diff --git a/ee/packages/den-db/drizzle/0019_llm_provider_opencode_oauth.sql b/ee/packages/den-db/drizzle/0019_llm_provider_opencode_oauth.sql new file mode 100644 index 0000000000..96f71ae066 --- /dev/null +++ b/ee/packages/den-db/drizzle/0019_llm_provider_opencode_oauth.sql @@ -0,0 +1,3 @@ +ALTER TABLE `llm_provider` + ADD COLUMN `credential_kind` enum('api_key','opencode_oauth') NOT NULL DEFAULT 'api_key' AFTER `provider_config`, + ADD COLUMN `opencode_auth` text AFTER `api_key`; diff --git a/ee/packages/den-db/drizzle/meta/_journal.json b/ee/packages/den-db/drizzle/meta/_journal.json index 071846a7df..dfbdc94036 100644 --- a/ee/packages/den-db/drizzle/meta/_journal.json +++ b/ee/packages/den-db/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1779380000000, "tag": "0018_invited_org_members", "breakpoints": true + }, + { + "idx": 19, + "version": "5", + "when": 1777486400000, + "tag": "0019_llm_provider_opencode_oauth", + "breakpoints": true } ] } diff --git a/ee/packages/den-db/src/schema/sharables/llm-providers.ts b/ee/packages/den-db/src/schema/sharables/llm-providers.ts index c4ded95170..25ad41480d 100644 --- a/ee/packages/den-db/src/schema/sharables/llm-providers.ts +++ b/ee/packages/den-db/src/schema/sharables/llm-providers.ts @@ -30,7 +30,11 @@ export const LlmProviderTable = mysqlTable( providerConfig: json("provider_config") .$type>() .notNull(), + credentialKind: mysqlEnum("credential_kind", ["api_key", "opencode_oauth"]) + .notNull() + .default("api_key"), apiKey: encryptedTextColumn("api_key"), + opencodeAuth: encryptedTextColumn("opencode_auth"), createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { fsp: 3 }) .notNull()