diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index 1100d31b64..13021cd5e6 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -431,6 +431,7 @@ async function acceptInvitation(invitation: InvitationRow, userId: UserId) { const invitedMember = invitedMemberRows[0] ?? null const existingMember = existingMemberRows[0] ?? null let member = existingMember + let createdMember = false if (!member && invitedMember) { await db @@ -446,6 +447,7 @@ async function acceptInvitation(invitation: InvitationRow, userId: UserId) { userId, role, }) + createdMember = true } if (invitation.teamId) { @@ -477,7 +479,7 @@ async function acceptInvitation(invitation: InvitationRow, userId: UserId) { .set({ status: "accepted" }) .where(eq(InvitationTable.id, invitation.id)) - return member + return { member, createdMember } } export async function acceptInvitationForUser(input: { @@ -514,11 +516,13 @@ export async function acceptInvitationForUser(input: { throw new OrganizationEmailDomainRestrictionError(input.email, allowedEmailDomains ?? []) } - const member = await acceptInvitation(invitation, input.userId) - await runPostOrganizationMemberChangeHooks({ organizationId: invitation.organizationId, memberId: member.id, change: "added" }) + const accepted = await acceptInvitation(invitation, input.userId) + if (accepted.createdMember) { + await runPostOrganizationMemberChangeHooks({ organizationId: invitation.organizationId, memberId: accepted.member.id, change: "added" }) + } return { invitation, - member, + member: accepted.member, } } diff --git a/ee/apps/den-api/src/routes/auth/desktop-handoff.ts b/ee/apps/den-api/src/routes/auth/desktop-handoff.ts index a2c6347a74..43e4174602 100644 --- a/ee/apps/den-api/src/routes/auth/desktop-handoff.ts +++ b/ee/apps/den-api/src/routes/auth/desktop-handoff.ts @@ -39,6 +39,13 @@ const grantNotFoundSchema = z.object({ message: z.string(), }).meta({ ref: "DesktopHandoffGrantNotFoundError" }) +class DesktopHandoffBaseUrlError extends Error { + constructor(message: string) { + super(message) + this.name = "DesktopHandoffBaseUrlError" + } +} + function readSingleHeader(value: string | null) { const first = value?.split(",")[0]?.trim() ?? "" return first || null @@ -90,7 +97,7 @@ function withDenProxyPath(origin: string) { return url.toString().replace(/\/+$/, "") } -function resolveDesktopDenBaseUrl(request: Request) { +export function resolveDesktopDenBaseUrl(request: Request) { const originHeader = readSingleHeader(request.headers.get("origin")) if (originHeader) { try { @@ -109,7 +116,7 @@ function resolveDesktopDenBaseUrl(request: Request) { const protocol = forwardedProto ?? new URL(request.url).protocol.replace(/:$/, "") const targetHost = forwardedHost ?? host if (!targetHost) { - return "https://app.openworklabs.com/api/den" + throw new DesktopHandoffBaseUrlError("Desktop handoff requires a valid request host or forwarded host.") } const origin = `${protocol}://${targetHost}` @@ -118,11 +125,10 @@ function resolveDesktopDenBaseUrl(request: Request) { if (isWebAppHost(url.hostname)) { return withDenProxyPath(url.origin) } + return origin } catch { - // Ignore invalid forwarded origins. + throw new DesktopHandoffBaseUrlError("Desktop handoff could not resolve a trusted Den base URL from request configuration.") } - - return origin } function buildOpenworkDeepLink(input: { @@ -175,7 +181,15 @@ export function registerDesktopAuthRoutes { @@ -105,6 +106,16 @@ const llmProviderWriteSchema = z.object({ message: "Paste a custom provider config.", }) } + + if (value.credentialKind === "opencode_oauth") { + if (value.source === "models_dev" && !isOpencodeOauthProviderAllowed(value.providerId)) { + 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({ @@ -133,18 +144,172 @@ const conflictSchema = z.object({ message: z.string().optional(), }).meta({ ref: "ConflictError" }) +const openAiOauthStartResponseSchema = z.object({ + verificationUrl: z.string(), + userCode: z.string(), + deviceAuthId: z.string(), + intervalMs: z.number(), +}).meta({ ref: "OpenAiOauthStartResponse" }) + +const openAiOauthCompleteSchema = z.object({ + deviceAuthId: z.string().trim().min(1), + userCode: z.string().trim().min(1), +}) + +const openAiOauthCompleteResponseSchema = z.object({ + opencodeAuth: z.string(), + accountId: z.string().nullable(), + expires: z.number(), +}).meta({ ref: "OpenAiOauthCompleteResponse" }) + function createFailure(status: number, error: string, message?: string): RouteFailure { return { status, error, message } } +function buildProviderAccessUserFallback(input: { + user: { id: string; name: string | null; email: string; image: string | null } | null + invitation: { email: string } | null +}) { + const email = input.user?.email ?? input.invitation?.email ?? "" + return { + id: input.user?.id ?? null, + name: input.user?.name ?? email, + email, + image: input.user?.image ?? null, + } +} + function isRouteFailure(value: unknown): value is RouteFailure { return typeof value === "object" && value !== null && "status" in value && "error" in value } +function parseJwtClaims(token: string): Record | null { + const parts = token.split(".") + if (parts.length !== 3 || !parts[1]) return null + try { + return JSON.parse(Buffer.from(parts[1], "base64url").toString()) as Record + } catch { + return null + } +} + +function extractOpenAiAccountId(tokens: { id_token?: string; access_token?: string }) { + const claims = tokens.id_token ? parseJwtClaims(tokens.id_token) : tokens.access_token ? parseJwtClaims(tokens.access_token) : null + if (!claims) return null + const apiAuth = claims["https://api.openai.com/auth"] + if (typeof claims.chatgpt_account_id === "string") return claims.chatgpt_account_id + if (apiAuth && typeof apiAuth === "object" && !Array.isArray(apiAuth) && typeof (apiAuth as Record).chatgpt_account_id === "string") { + return (apiAuth as Record).chatgpt_account_id + } + const organizations = claims.organizations + if (Array.isArray(organizations)) { + const first = organizations.find((entry): entry is Record => typeof entry === "object" && entry !== null && !Array.isArray(entry)) + if (typeof first?.id === "string") return first.id + } + return null +} + +export async function startOpenAiDeviceAuth() { + const response = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "opencode/den", + }, + body: JSON.stringify({ client_id: OPENAI_CODEX_CLIENT_ID }), + }) + if (!response.ok) { + throw createFailure(502, "openai_oauth_start_failed", `OpenAI device authorization failed with ${response.status}.`) + } + const data = await response.json() as { device_auth_id?: unknown; user_code?: unknown; interval?: unknown } + if (typeof data.device_auth_id !== "string" || typeof data.user_code !== "string") { + throw createFailure(502, "openai_oauth_start_failed", "OpenAI device authorization response was incomplete.") + } + const interval = Math.max(Number.parseInt(String(data.interval ?? "5"), 10) || 5, 1) * 1000 + return { + verificationUrl: `${OPENAI_AUTH_ISSUER}/codex/device`, + userCode: data.user_code, + deviceAuthId: data.device_auth_id, + intervalMs: interval + OPENAI_DEVICE_POLLING_SAFETY_MARGIN_MS, + } +} + +export async function completeOpenAiDeviceAuth(input: { deviceAuthId: string; userCode: string }) { + const deviceResponse = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "opencode/den", + }, + body: JSON.stringify({ + device_auth_id: input.deviceAuthId, + user_code: input.userCode, + }), + }) + + if (deviceResponse.status === 403) { + throw createFailure(409, "openai_oauth_pending", "OpenAI authorization is not complete yet.") + } + if (deviceResponse.status === 404) { + throw createFailure(410, "openai_oauth_expired", "OpenAI authorization expired. Restart the device authorization flow.") + } + if (!deviceResponse.ok) { + throw createFailure(502, "openai_oauth_complete_failed", `OpenAI device authorization failed with ${deviceResponse.status}.`) + } + const deviceData = await deviceResponse.json() as { authorization_code?: unknown; code_verifier?: unknown } + if (typeof deviceData.authorization_code !== "string" || typeof deviceData.code_verifier !== "string") { + throw createFailure(502, "openai_oauth_complete_failed", "OpenAI device token response was incomplete.") + } + + const tokenResponse = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: deviceData.authorization_code, + redirect_uri: OPENAI_DEVICE_REDIRECT_URI, + client_id: OPENAI_CODEX_CLIENT_ID, + code_verifier: deviceData.code_verifier, + }).toString(), + }) + if (!tokenResponse.ok) { + throw createFailure(502, "openai_oauth_complete_failed", `OpenAI token exchange failed with ${tokenResponse.status}.`) + } + const tokens = await tokenResponse.json() as { id_token?: string; access_token?: string; refresh_token?: string; expires_in?: number } + if (!tokens.access_token || !tokens.refresh_token) { + throw createFailure(502, "openai_oauth_complete_failed", "OpenAI token response did not include OAuth tokens.") + } + const expires = Date.now() + (tokens.expires_in ?? 3600) * 1000 + const accountId = extractOpenAiAccountId(tokens) + return { + opencodeAuth: JSON.stringify({ + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires, + ...(accountId ? { accountId } : {}), + }), + accountId, + expires, + } +} + function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: string } }) { return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin") } +export function isOpencodeOauthProviderAllowed(providerId: string | undefined | null) { + return providerId?.trim().toLowerCase() === "openai" +} + +export function canUseOpenAiOAuthCredentialFlow(payload: { currentMember: { isOwner: boolean; role: string } }) { + return isOrganizationAdmin(payload) +} + +export function canImportLlmProviderCredential(payload: { currentMember: { isOwner: boolean; role: string } }) { + return isOrganizationAdmin(payload) +} + function canManageLlmProvider( payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, provider: LlmProviderRow, @@ -175,6 +340,66 @@ function parseLlmProviderAccessId(value: string) { return normalizeDenTypeId("llmProviderAccess", value) } +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, + } +} + +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 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)) { + throw new Error("OpenCode OAuth auth must be a JSON object.") + } + const auth = parsed as Record + if (auth.type !== "oauth") { + throw new Error('OpenCode OAuth auth must include "type": "oauth".') + } + if (typeof auth.access !== "string" || !auth.access.trim()) { + throw new Error("OpenCode OAuth auth must include an access token.") + } + if (typeof auth.refresh !== "string" || !auth.refresh.trim()) { + throw new Error("OpenCode OAuth auth must include a refresh token.") + } + if (typeof auth.expires !== "number" || !Number.isFinite(auth.expires) || auth.expires < 0) { + throw new Error("OpenCode OAuth auth must include a non-negative numeric expires value.") + } + } catch (error) { + throw createFailure( + 400, + "invalid_opencode_auth", + error instanceof Error ? error.message : "OpenCode OAuth auth must be valid JSON.", + ) + } + + return trimmed +} + function parseMemberId(value: string) { return normalizeDenTypeId("member", value) } @@ -215,7 +440,7 @@ async function listAccessibleProviderAccess(input: { .where(accessWhere) } -async function resolveMemberIds(input: { +export async function resolveMemberIds(input: { organizationId: typeof LlmProviderTable.$inferSelect.organizationId values: string[] }) { @@ -235,7 +460,11 @@ async function resolveMemberIds(input: { const rows = await db .select({ id: MemberTable.id }) .from(MemberTable) - .where(and(eq(MemberTable.organizationId, input.organizationId), inArray(MemberTable.id, memberIds), isNull(MemberTable.removedAt))) + .where(and( + eq(MemberTable.organizationId, input.organizationId), + inArray(MemberTable.id, memberIds), + isNull(MemberTable.removedAt), + )) if (rows.length !== memberIds.length) { throw createFailure(404, "member_not_found") @@ -274,6 +503,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 +523,9 @@ async function normalizeLlmProviderInput(input: z.infer @@ -394,6 +632,7 @@ async function loadLlmProviders(input: { member: { id: MemberTable.id, role: MemberTable.role, + removedAt: MemberTable.removedAt, }, user: { id: AuthUserTable.id, @@ -409,7 +648,11 @@ async function loadLlmProviders(input: { .innerJoin(MemberTable, eq(LlmProviderAccessTable.orgMembershipId, MemberTable.id)) .leftJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id)) .leftJoin(InvitationTable, eq(MemberTable.inviteId, InvitationTable.id)) - .where(and(inArray(LlmProviderAccessTable.llmProviderId, providerIds), isNotNull(LlmProviderAccessTable.orgMembershipId), isNull(MemberTable.removedAt))) + .where(and( + inArray(LlmProviderAccessTable.llmProviderId, providerIds), + isNotNull(LlmProviderAccessTable.orgMembershipId), + isNull(MemberTable.removedAt), + )) const teamAccessRows = await db .select({ @@ -438,6 +681,7 @@ async function loadLlmProviders(input: { const memberAccessByProviderId = new Map() for (const row of memberAccessRows) { + if (row.member.removedAt) continue const existing = memberAccessByProviderId.get(row.access.llmProviderId) ?? [] existing.push(row) memberAccessByProviderId.set(row.access.llmProviderId, existing) @@ -464,7 +708,7 @@ async function loadLlmProviders(input: { return providers.map((provider) => ({ ...provider, - hasApiKey: Boolean(provider.apiKey && provider.apiKey.trim().length > 0), + ...getCredentialFlags(provider), models: (modelsByProviderId.get(provider.id) ?? []) .map((model) => ({ id: model.modelId, @@ -474,21 +718,13 @@ async function loadLlmProviders(input: { })) .sort((left, right) => left.name.localeCompare(right.name)), access: { - members: (memberAccessByProviderId.get(provider.id) ?? []).map((row) => { - const email = row.user?.email ?? row.invitation?.email ?? "invited@example.com" - return { - id: row.access.id, - orgMembershipId: row.member.id, - role: row.member.role, - user: { - id: row.user?.id ?? row.member.id, - name: row.user?.name ?? getInvitedMemberName(email), - email, - image: row.user?.image ?? null, - }, - createdAt: row.access.createdAt, - } - }), + members: (memberAccessByProviderId.get(provider.id) ?? []).map((row) => ({ + id: row.access.id, + orgMembershipId: row.member.id, + role: row.member.role, + user: buildProviderAccessUserFallback({ user: row.user, invitation: row.invitation }), + createdAt: row.access.createdAt, + })), teams: (teamAccessByProviderId.get(provider.id) ?? []).map((row) => ({ id: row.access.id, teamId: row.team.id, @@ -502,6 +738,69 @@ async function loadLlmProviders(input: { } export function registerOrgLlmProviderRoutes }>(app: Hono) { + app.post( + "/v1/llm-providers/openai-oauth/start", + describeRoute({ + tags: ["LLM Providers"], + summary: "Start OpenAI OAuth device flow", + description: "Starts the same OpenAI/ChatGPT device auth flow used by OpenCode and returns the user code.", + responses: { + 200: jsonResponse("OpenAI OAuth device flow started successfully.", openAiOauthStartResponseSchema), + 401: jsonResponse("The caller must be signed in to connect OpenAI.", unauthorizedSchema), + 403: jsonResponse("Only organization admins can connect OpenAI OAuth credentials.", forbiddenSchema), + 502: jsonResponse("OpenAI OAuth could not be started.", conflictSchema), + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const payload = c.get("organizationContext") + if (!canUseOpenAiOAuthCredentialFlow(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can connect OpenAI OAuth credentials." }, 403) + } + + try { + return c.json(await startOpenAiDeviceAuth()) + } catch (error) { + if (isRouteFailure(error)) return c.json({ error: error.error, message: error.message }, { status: error.status as 409 | 502 }) + throw error + } + }, + ) + + app.post( + "/v1/llm-providers/openai-oauth/complete", + describeRoute({ + tags: ["LLM Providers"], + summary: "Complete OpenAI OAuth device flow", + description: "Completes OpenAI device auth and returns an OpenCode-native OAuth auth object serialized as JSON.", + responses: { + 200: jsonResponse("OpenAI OAuth completed successfully.", openAiOauthCompleteResponseSchema), + 401: jsonResponse("The caller must be signed in to complete OpenAI auth.", unauthorizedSchema), + 403: jsonResponse("Only organization admins can connect OpenAI OAuth credentials.", forbiddenSchema), + 409: jsonResponse("OpenAI authorization is still pending.", conflictSchema), + 502: jsonResponse("OpenAI OAuth could not be completed.", conflictSchema), + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + jsonValidator(openAiOauthCompleteSchema), + async (c) => { + const payload = c.get("organizationContext") + if (!canUseOpenAiOAuthCredentialFlow(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can connect OpenAI OAuth credentials." }, 403) + } + + const input = c.req.valid("json") + try { + return c.json(await completeOpenAiDeviceAuth(input)) + } catch (error) { + if (isRouteFailure(error)) return c.json({ error: error.error, message: error.message }, { status: error.status as 409 | 502 }) + throw error + } + }, + ) + app.get( "/v1/llm-provider-catalog", describeRoute({ @@ -607,8 +906,7 @@ export function registerOrgLlmProviderRoutes ({ - ...provider, - apiKey: undefined, + ...redactLlmProviderCredentials(provider), canManage: canManageLlmProvider(payload, provider), })), }) @@ -677,7 +975,82 @@ 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: "Get LLM provider import credential", + description: "Returns a stored organization LLM provider credential for explicit import into a managed OpenCode client.", + responses: { + 200: jsonResponse("Provider import credential returned successfully.", llmProviderResponseSchema), + 400: jsonResponse("The provider import 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, + resolveMemberTeamsMiddleware, + async (c) => { + const payload = c.get("organizationContext") + const memberTeams = c.get("memberTeams") ?? [] + 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) + } + + const canImport = canImportLlmProviderCredential(payload) + || await canAccessLlmProvider({ + organizationId: payload.organization.id, + llmProviderId, + currentMemberId: payload.currentMember.id, + memberTeams, + }) + + if (!canImport) { + return c.json({ error: "forbidden", message: "Only members with access to this provider can import its credential." }, 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 +1105,12 @@ export function registerOrgLlmProviderRoutes { + seedRequiredEnv() + desktopHandoffModule = await import("../src/routes/auth/desktop-handoff.js") +}) + +test("desktop handoff Den base URL resolves trusted web app origin through proxy path", () => { + const request = new Request("http://den-api.internal/v1/auth/desktop-handoff", { + headers: { origin: "https://app.openworklabs.com" }, + }) + + expect(desktopHandoffModule.resolveDesktopDenBaseUrl(request)).toBe("https://app.openworklabs.com/api/den") +}) + +test("desktop handoff Den base URL fails closed instead of falling back to public cloud", () => { + const request = new Request("http://den-api.internal/v1/auth/desktop-handoff", { + headers: { host: "", "x-forwarded-host": "bad host", "x-forwarded-proto": "https" }, + }) + + expect(() => desktopHandoffModule.resolveDesktopDenBaseUrl(request)).toThrow("trusted Den base URL") +}) diff --git a/ee/apps/den-api/test/llm-provider-access-lifecycle.test.ts b/ee/apps/den-api/test/llm-provider-access-lifecycle.test.ts new file mode 100644 index 0000000000..fd191273d6 --- /dev/null +++ b/ee/apps/den-api/test/llm-provider-access-lifecycle.test.ts @@ -0,0 +1,170 @@ +import { beforeAll, beforeEach, expect, mock, test } from "bun:test" +import { createDenTypeId } from "@openwork-ee/utils/typeid" + +function seedRequiredEnv() { + process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_test" + process.env.DEN_DB_ENCRYPTION_KEY = process.env.DEN_DB_ENCRYPTION_KEY ?? "x".repeat(32) + process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "y".repeat(32) + process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:8790" + process.env.CORS_ORIGINS = process.env.CORS_ORIGINS ?? "http://127.0.0.1:8790" +} + +let queryRows: unknown[][] = [] + +function queryFor(rows: unknown[]) { + const chain: any = { + from: () => chain, + innerJoin: () => chain, + leftJoin: () => chain, + where: () => chain, + orderBy: () => rows, + limit: () => rows, + then: (resolve: (value: unknown[]) => unknown) => resolve(rows), + } + return chain +} + +let llmProviderModule: typeof import("../src/routes/org/llm-providers.js") + +beforeAll(async () => { + seedRequiredEnv() + mock.module("../src/db.js", () => ({ + db: { + select: () => queryFor(queryRows.shift() ?? []), + }, + })) + + llmProviderModule = await import("../src/routes/org/llm-providers.js") +}) + +beforeEach(() => { + queryRows = [] +}) + +test("active members remain assignable to LLM provider access", async () => { + const organizationId = createDenTypeId("organization") + const activeMemberId = createDenTypeId("member") + queryRows = [[{ id: activeMemberId }]] + + await expect(llmProviderModule.resolveMemberIds({ + organizationId, + values: [activeMemberId], + })).resolves.toEqual([activeMemberId]) +}) + +test("removed members are rejected when assigning LLM provider access", async () => { + const organizationId = createDenTypeId("organization") + const removedMemberId = createDenTypeId("member") + queryRows = [[]] + + await expect(llmProviderModule.resolveMemberIds({ + organizationId, + values: [removedMemberId], + })).rejects.toMatchObject({ + status: 404, + error: "member_not_found", + }) +}) + +test("existing access rows to removed members are not returned as active grants", async () => { + const organizationId = createDenTypeId("organization") + const currentMemberId = createDenTypeId("member") + const activeMemberId = createDenTypeId("member") + const removedMemberId = createDenTypeId("member") + const llmProviderId = createDenTypeId("llmProvider") + const now = new Date("2026-06-09T00:00:00.000Z") + + queryRows = [ + [], + [{ + id: llmProviderId, + organizationId, + createdByOrgMembershipId: currentMemberId, + source: "models_dev", + credentialKind: "api_key", + providerId: "openai", + name: "OpenAI", + providerConfig: {}, + apiKey: "sk-test", + opencodeAuth: null, + createdAt: now, + updatedAt: now, + }], + [], + [ + { + access: { id: createDenTypeId("llmProviderAccess"), llmProviderId, createdAt: now }, + member: { id: activeMemberId, role: "member", removedAt: null }, + user: { id: createDenTypeId("user"), name: "Active", email: "active@example.com", image: null }, + }, + { + access: { id: createDenTypeId("llmProviderAccess"), llmProviderId, createdAt: now }, + member: { id: removedMemberId, role: "member", removedAt: new Date("2026-06-08T00:00:00.000Z") }, + user: { id: createDenTypeId("user"), name: "Removed", email: "removed@example.com", image: null }, + }, + ], + [], + ] + + const providers = await llmProviderModule.loadLlmProviders({ + organizationId, + currentMemberId, + memberTeams: [], + isAdmin: true, + scope: "manageable", + }) + + expect(providers).toHaveLength(1) + expect(providers[0]?.access.members.map((member) => member.orgMembershipId)).toEqual([activeMemberId]) +}) + +test("pending invited members remain visible in LLM provider access listings", async () => { + const organizationId = createDenTypeId("organization") + const currentMemberId = createDenTypeId("member") + const pendingMemberId = createDenTypeId("member") + const llmProviderId = createDenTypeId("llmProvider") + const now = new Date("2026-06-10T00:00:00.000Z") + + queryRows = [ + [], + [{ + id: llmProviderId, + organizationId, + createdByOrgMembershipId: currentMemberId, + source: "models_dev", + credentialKind: "api_key", + providerId: "openai", + name: "OpenAI", + providerConfig: {}, + apiKey: "sk-test", + opencodeAuth: null, + createdAt: now, + updatedAt: now, + }], + [], + [{ + access: { id: createDenTypeId("llmProviderAccess"), llmProviderId, createdAt: now }, + member: { id: pendingMemberId, role: "member", removedAt: null }, + user: null, + invitation: { email: "pending@example.com" }, + }], + [], + ] + + const providers = await llmProviderModule.loadLlmProviders({ + organizationId, + currentMemberId, + memberTeams: [], + isAdmin: true, + scope: "manageable", + }) + + expect(providers).toHaveLength(1) + expect(providers[0]?.access.members).toEqual([{ + id: expect.any(String), + orgMembershipId: pendingMemberId, + role: "member", + user: { id: null, name: "pending@example.com", email: "pending@example.com", image: null }, + createdAt: now, + }]) +}) diff --git a/ee/apps/den-api/test/llm-provider-credentials.test.ts b/ee/apps/den-api/test/llm-provider-credentials.test.ts new file mode 100644 index 0000000000..71ea6e92a9 --- /dev/null +++ b/ee/apps/den-api/test/llm-provider-credentials.test.ts @@ -0,0 +1,61 @@ +import { beforeAll, expect, test } from "bun:test" +import { Hono } from "hono" + +function seedRequiredEnv() { + process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_test" + process.env.DEN_DB_ENCRYPTION_KEY = process.env.DEN_DB_ENCRYPTION_KEY ?? "x".repeat(32) + process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "y".repeat(32) + process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:8790" + process.env.CORS_ORIGINS = process.env.CORS_ORIGINS ?? "http://127.0.0.1:8790" +} + +let llmProviderModule: typeof import("../src/routes/org/llm-providers.js") + +beforeAll(async () => { + 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/apps/den-api/test/llm-providers-oauth.test.ts b/ee/apps/den-api/test/llm-providers-oauth.test.ts new file mode 100644 index 0000000000..4786b321ad --- /dev/null +++ b/ee/apps/den-api/test/llm-providers-oauth.test.ts @@ -0,0 +1,220 @@ +import { beforeAll, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import { Hono } from "hono" + +function seedRequiredEnv() { + process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_test" + process.env.DEN_DB_ENCRYPTION_KEY = process.env.DEN_DB_ENCRYPTION_KEY ?? "x".repeat(32) + process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "y".repeat(32) + process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:8790" + process.env.CORS_ORIGINS = process.env.CORS_ORIGINS ?? "http://127.0.0.1:8790" +} + +let llmProviderModule: typeof import("../src/routes/org/llm-providers.js") + +beforeAll(async () => { + seedRequiredEnv() + llmProviderModule = await import("../src/routes/org/llm-providers.js") +}) + +function createRouteApp() { + const app = new Hono() + llmProviderModule.registerOrgLlmProviderRoutes(app) + return app +} + +function jwtWithClaims(claims: Record) { + return [ + Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"), + Buffer.from(JSON.stringify(claims)).toString("base64url"), + "signature", + ].join(".") +} + +test("generic provider payload redaction removes API key and OAuth auth material", () => { + const redacted = llmProviderModule.redactLlmProviderCredentials({ + id: "llmProvider_secret_123", + apiKey: "sk-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: "sk-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + })).toEqual({ hasApiKey: true, hasOpencodeAuth: true, hasCredential: true }) +}) + +test("OpenCode OAuth credential type rejects non-OpenAI providers", () => { + expect(llmProviderModule.isOpencodeOauthProviderAllowed("openai")).toBe(true) + expect(llmProviderModule.isOpencodeOauthProviderAllowed(" OpenAI ")).toBe(true) + expect(llmProviderModule.isOpencodeOauthProviderAllowed("anthropic")).toBe(false) + expect(llmProviderModule.isOpencodeOauthProviderAllowed(undefined)).toBe(false) +}) + +test("OAuth credential and import permission gates require 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.canUseOpenAiOAuthCredentialFlow(owner)).toBe(true) + expect(llmProviderModule.canUseOpenAiOAuthCredentialFlow(admin)).toBe(true) + expect(llmProviderModule.canUseOpenAiOAuthCredentialFlow(creatorOnly)).toBe(false) + expect(llmProviderModule.canImportLlmProviderCredential(owner)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(admin)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(creatorOnly)).toBe(false) +}) + +test("OpenAI OAuth routes require an authenticated caller before returning credential material", async () => { + const app = createRouteApp() + + const startResponse = await app.request("http://den.local/v1/llm-providers/openai-oauth/start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(startResponse.status).toBe(401) + await expect(startResponse.json()).resolves.toEqual({ error: "unauthorized" }) + + const completeResponse = await app.request("http://den.local/v1/llm-providers/openai-oauth/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceAuthId: "dev", userCode: "code" }), + }) + expect(completeResponse.status).toBe(401) + await expect(completeResponse.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("purpose-specific import endpoint requires authentication", async () => { + const app = createRouteApp() + 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" }) +}) + +test("OpenAI OAuth completion reports pending authorization without tokens", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => new Response(JSON.stringify({}), { status: 403 })) as typeof fetch + try { + await expect(llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-pending", + userCode: "CODE", + })).rejects.toMatchObject({ error: "openai_oauth_pending", status: 409 }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth completion reports expired device authorization separately from pending", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => new Response(JSON.stringify({ error: "not_found" }), { status: 404 })) as typeof fetch + try { + await expect(llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-expired", + userCode: "CODE", + })).rejects.toMatchObject({ error: "openai_oauth_expired", status: 410 }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth start reports upstream failure without credential material", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => new Response(JSON.stringify({ error: "upstream" }), { status: 500 })) as typeof fetch + try { + await expect(llmProviderModule.startOpenAiDeviceAuth()).rejects.toMatchObject({ + error: "openai_oauth_start_failed", + status: 502, + }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth completion reports token exchange failure without credential material", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ authorization_code: "authorization-code", code_verifier: "verifier" }) + } + if (url.endsWith("/oauth/token")) { + return new Response(JSON.stringify({ error: "exchange_failed" }), { status: 500 }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + + try { + await expect(llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-failure", + userCode: "CODE", + })).rejects.toMatchObject({ error: "openai_oauth_complete_failed", status: 502 }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth completion returns importable OpenCode OAuth auth on success", async () => { + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input) + calls.push(url) + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ authorization_code: "authorization-code", code_verifier: "verifier" }) + } + if (url.endsWith("/oauth/token")) { + return Response.json({ + access_token: jwtWithClaims({ chatgpt_account_id: "acct_123" }), + refresh_token: "refresh-token", + expires_in: 60, + }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + + try { + const completed = await llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-complete", + userCode: "CODE", + }) + const auth = JSON.parse(completed.opencodeAuth) as Record + + expect(calls).toHaveLength(2) + expect(auth.type).toBe("oauth") + expect(typeof auth.access).toBe("string") + expect(auth.refresh).toBe("refresh-token") + expect(auth.accountId).toBe("acct_123") + expect(completed.accountId).toBe("acct_123") + expect(typeof auth.expires).toBe("number") + } finally { + globalThis.fetch = originalFetch + } +}) + +test("LLM provider migration journal remains valid JSON", async () => { + const journal = await readFile(new URL("../../../packages/den-db/drizzle/meta/_journal.json", import.meta.url), "utf8") + const parsed = JSON.parse(journal) as { entries?: Array<{ tag?: string }> } + + expect(parsed.entries?.some((entry) => entry.tag === "0021_llm_provider_opencode_oauth")).toBe(true) +}) + +test("LLM provider OAuth migration snapshot metadata is present", async () => { + const snapshot = await readFile(new URL("../../../packages/den-db/drizzle/meta/0021_snapshot.json", import.meta.url), "utf8") + const parsed = JSON.parse(snapshot) as { tables?: Record }> } + const llmProviderColumns = parsed.tables?.llm_provider?.columns ?? {} + + expect("credential_kind" in llmProviderColumns).toBe(true) + expect("opencode_auth" in llmProviderColumns).toBe(true) +}) diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.test.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.test.tsx new file mode 100644 index 0000000000..852a429729 --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.test.tsx @@ -0,0 +1,62 @@ +import { parseDenLlmProvidersResponse } from "./llm-provider-data"; + +declare const test: (name: string, fn: () => void | Promise) => void; +declare const expect: any; + +test("parses pending invited LLM provider member access entries", () => { + const parsed = parseDenLlmProvidersResponse({ + llmProviders: [ + { + id: "llmProvider_pending", + organizationId: "org_pending", + createdByOrgMembershipId: "om_creator", + source: "models_dev", + providerId: "openai", + name: "OpenAI", + providerConfig: {}, + credentialKind: "opencode_oauth", + hasApiKey: false, + hasOpencodeAuth: true, + hasCredential: true, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + canManage: true, + accessibleVia: { orgMembershipIds: [], teamIds: [] }, + models: [], + access: { + members: [ + { + id: "llmProviderAccess_pending", + orgMembershipId: "om_pending", + role: "member", + user: { + id: null, + name: "pending@example.com", + email: "pending@example.com", + image: null, + }, + createdAt: "2026-06-10T00:00:00.000Z", + }, + ], + teams: [], + }, + }, + ], + }); + + expect(parsed).toHaveLength(1); + expect(parsed[0]?.access.members).toEqual([ + { + id: "llmProviderAccess_pending", + orgMembershipId: "om_pending", + role: "member", + user: { + id: null, + name: "pending@example.com", + email: "pending@example.com", + image: null, + }, + createdAt: "2026-06-10T00:00:00.000Z", + }, + ]); +}); diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx index 751b911462..4545c12581 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { getErrorMessage, requestJson } from "../../_lib/den-flow"; export type DenLlmProviderSource = "models_dev" | "custom" | "openwork"; +export type DenLlmProviderCredentialKind = "api_key" | "opencode_oauth"; export type DenLlmProviderModel = { id: string; @@ -18,7 +19,7 @@ export type DenLlmProviderMemberAccess = { role: string; createdAt: string | null; user: { - id: string; + id: string | null; name: string; email: string; image: string | null; @@ -41,7 +42,10 @@ export type DenLlmProvider = { providerId: string; name: string; providerConfig: Record; + credentialKind: DenLlmProviderCredentialKind; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; createdAt: string | null; updatedAt: string | null; canManage: boolean; @@ -122,10 +126,12 @@ function asLlmProviderMemberAccess(value: unknown): DenLlmProviderMemberAccess | const id = asString(value.id); const orgMembershipId = asString(value.orgMembershipId); const role = asString(value.role); - const userId = asString(value.user.id); + const rawUserId = value.user.id; + const userId = rawUserId === null ? null : asString(rawUserId); + const validUserId = rawUserId === null || (typeof rawUserId === "string" && rawUserId.length > 0); const name = asString(value.user.name); const email = asString(value.user.email); - if (!id || !orgMembershipId || !role || !userId || !name || !email) { + if (!id || !orgMembershipId || !role || !validUserId || !name || !email) { return null; } @@ -143,6 +149,12 @@ function asLlmProviderMemberAccess(value: unknown): DenLlmProviderMemberAccess | }; } +export function parseDenLlmProvidersResponse(payload: unknown): DenLlmProvider[] { + return isRecord(payload) && Array.isArray(payload.llmProviders) + ? payload.llmProviders.map(asLlmProvider).filter((entry): entry is DenLlmProvider => entry !== null) + : []; +} + function asLlmProviderTeamAccess(value: unknown): DenLlmProviderTeamAccess | null { if (!isRecord(value)) { return null; @@ -178,6 +190,7 @@ function asLlmProvider(value: unknown): DenLlmProvider | null { value.source === "models_dev" || value.source === "custom" || value.source === "openwork" ? value.source : null; + const credentialKind = value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key"; if (!id || !organizationId || !createdByOrgMembershipId || !providerId || !name || !source) { return null; } @@ -190,7 +203,10 @@ function asLlmProvider(value: unknown): DenLlmProvider | null { providerId, name, providerConfig: asJsonRecord(value.providerConfig), + credentialKind, hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, createdAt: asIsoString(value.createdAt), updatedAt: asIsoString(value.updatedAt), canManage: value.canManage === true, @@ -417,9 +433,7 @@ export function useOrgLlmProviders( throw new Error(getErrorMessage(payload, `Failed to load providers (${response.status}).`)); } - const nextProviders = isRecord(payload) && Array.isArray(payload.llmProviders) - ? payload.llmProviders.map(asLlmProvider).filter((entry): entry is DenLlmProvider => entry !== null) - : []; + const nextProviders = parseDenLlmProvidersResponse(payload); setLlmProviders(nextProviders); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : "Failed to load the provider library."); diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx index c208bfdaa9..4b50aac65c 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx @@ -185,10 +185,10 @@ export function LlmProviderDetailScreen({
- {provider.hasApiKey + {provider.hasCredential ? "Credential saved" : "Credential missing"}
@@ -221,10 +221,10 @@ export function LlmProviderDetailScreen({

- Updated + Credential

- {formatProviderTimestamp(provider.updatedAt)} + {provider.credentialKind === "opencode_oauth" ? "OpenCode OAuth" : "API key"}

diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx index fec4fc356b..d07a559201 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx @@ -34,6 +34,7 @@ import { requestLlmProviderCatalogDetail, useOrgLlmProviders, type DenLlmProvider, + type DenLlmProviderCredentialKind, type DenModelsDevProviderDetail, type DenModelsDevProviderSummary, } from "./llm-provider-data"; @@ -88,7 +89,18 @@ export function LlmProviderEditorScreen({ const [customConfigText, setCustomConfigText] = useState( buildCustomProviderTemplate(), ); + const [credentialKind, setCredentialKind] = + useState("api_key"); const [apiKey, setApiKey] = useState(""); + const [opencodeAuth, setOpencodeAuth] = useState(""); + const [openAiOauthBusy, setOpenAiOauthBusy] = useState(false); + const [openAiOauthError, setOpenAiOauthError] = useState(null); + const [openAiOauthSession, setOpenAiOauthSession] = useState<{ + verificationUrl: string; + userCode: string; + deviceAuthId: string; + intervalMs: number; + } | null>(null); const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [selectedTeamIds, setSelectedTeamIds] = useState([]); const [saveBusy, setSaveBusy] = useState(false); @@ -146,7 +158,11 @@ export function LlmProviderEditorScreen({ ? buildEditableCustomProviderText(provider) : buildCustomProviderTemplate(), ); + setCredentialKind(provider.credentialKind); setApiKey(""); + setOpencodeAuth(""); + setOpenAiOauthError(null); + setOpenAiOauthSession(null); return; } @@ -159,9 +175,90 @@ export function LlmProviderEditorScreen({ ); setSelectedTeamIds([]); setCustomConfigText(buildCustomProviderTemplate()); + setCredentialKind("api_key"); setApiKey(""); + setOpencodeAuth(""); + setOpenAiOauthError(null); + setOpenAiOauthSession(null); }, [orgContext?.currentMember.id, provider]); + useEffect(() => { + setOpenAiOauthError(null); + setOpenAiOauthSession(null); + }, [credentialKind, selectedProviderId, source]); + + async function startOpenAiOauth() { + setOpenAiOauthBusy(true); + setOpenAiOauthError(null); + try { + const { response, payload } = await requestJson( + "/v1/llm-providers/openai-oauth/start", + { method: "POST", body: JSON.stringify({}) }, + 20000, + ); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to start OpenAI OAuth (${response.status}).`)); + } + if (!payload || typeof payload !== "object") { + throw new Error("OpenAI OAuth response was empty."); + } + const data = payload as Record; + if ( + typeof data.verificationUrl !== "string" || + typeof data.userCode !== "string" || + typeof data.deviceAuthId !== "string" || + typeof data.intervalMs !== "number" + ) { + throw new Error("OpenAI OAuth response was incomplete."); + } + setOpenAiOauthSession({ + verificationUrl: data.verificationUrl, + userCode: data.userCode, + deviceAuthId: data.deviceAuthId, + intervalMs: data.intervalMs, + }); + window.open(data.verificationUrl, "_blank", "noopener,noreferrer"); + } catch (error) { + setOpenAiOauthError(error instanceof Error ? error.message : "Could not start OpenAI OAuth."); + } finally { + setOpenAiOauthBusy(false); + } + } + + async function completeOpenAiOauth() { + if (!openAiOauthSession) { + setOpenAiOauthError("Start OpenAI OAuth first."); + return; + } + setOpenAiOauthBusy(true); + setOpenAiOauthError(null); + try { + const { response, payload } = await requestJson( + "/v1/llm-providers/openai-oauth/complete", + { + method: "POST", + body: JSON.stringify({ + deviceAuthId: openAiOauthSession.deviceAuthId, + userCode: openAiOauthSession.userCode, + }), + }, + 20000, + ); + if (!response.ok) { + throw new Error(getErrorMessage(payload, response.status === 409 ? "OpenAI authorization is not complete yet." : `Failed to complete OpenAI OAuth (${response.status}).`)); + } + if (!payload || typeof payload !== "object" || typeof (payload as Record).opencodeAuth !== "string") { + throw new Error("OpenAI OAuth completion response was incomplete."); + } + setOpencodeAuth((payload as { opencodeAuth: string }).opencodeAuth); + setOpenAiOauthSession(null); + } catch (error) { + setOpenAiOauthError(error instanceof Error ? error.message : "Could not complete OpenAI OAuth."); + } finally { + setOpenAiOauthBusy(false); + } + } + useEffect(() => { if (source !== "models_dev" || !orgId || !selectedProviderId) { setCatalogDetail(null); @@ -286,6 +383,11 @@ export function LlmProviderEditorScreen({ } } + if (credentialKind === "opencode_oauth" && source === "models_dev" && selectedProviderId !== "openai") { + setSaveError("OpenCode OAuth credentials are only available for the OpenAI catalog provider."); + return; + } + if (source === "custom" && !customConfigText.trim()) { setSaveError("Paste a custom provider config."); return; @@ -297,6 +399,7 @@ export function LlmProviderEditorScreen({ const body: Record = { name: providerName.trim(), source, + credentialKind, memberIds: [...new Set(selectedMemberIds)], teamIds: [...new Set(selectedTeamIds)], }; @@ -308,10 +411,14 @@ export function LlmProviderEditorScreen({ body.customConfigText = customConfigText; } - if (apiKey.trim() || !provider) { + if (credentialKind === "api_key" && (apiKey.trim() || !provider || provider.credentialKind !== "api_key")) { body.apiKey = apiKey.trim(); } + if (credentialKind === "opencode_oauth" && (opencodeAuth.trim() || !provider || provider.credentialKind !== "opencode_oauth")) { + body.opencodeAuth = opencodeAuth.trim(); + } + const path = provider ? `/v1/llm-providers/${encodeURIComponent(provider.id)}` : `/v1/llm-providers`; @@ -607,28 +714,113 @@ export function LlmProviderEditorScreen({ Credential - {provider?.hasApiKey ? ( + {provider?.hasCredential ? ( Existing credential saved ) : null} -