feat(auth): add Anthropic OAuth with PKCE
Browser-based OAuth flow using Anthropic's hosted callback. Users authorize on claude.ai, paste the code back, and tokens are encrypted and stored in D1. Includes auto-refresh, Bearer auth via custom fetch wrapper, and mcp_ tool name prefixing required by the OAuth endpoint. Also fixes provider-config save bug that required encryption key unconditionally — now only checks when API key is present.
This commit is contained in:
parent
3f8d273986
commit
c53f3a5fac
8
drizzle/0029_fantastic_mach_iv.sql
Normal file
8
drizzle/0029_fantastic_mach_iv.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE `anthropic_oauth_tokens` (
|
||||||
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`access_token` text NOT NULL,
|
||||||
|
`refresh_token` text NOT NULL,
|
||||||
|
`expires_at` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
5403
drizzle/meta/0029_snapshot.json
Normal file
5403
drizzle/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -204,6 +204,13 @@
|
|||||||
"when": 1771295883108,
|
"when": 1771295883108,
|
||||||
"tag": "0028_small_old_lace",
|
"tag": "0028_small_old_lace",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771303716734,
|
||||||
|
"tag": "0029_fantastic_mach_iv",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -3,11 +3,41 @@ import type { ProviderConfig } from "./types"
|
|||||||
|
|
||||||
export function createClient(provider: ProviderConfig): Anthropic {
|
export function createClient(provider: ProviderConfig): Anthropic {
|
||||||
switch (provider.type) {
|
switch (provider.type) {
|
||||||
case "anthropic":
|
case "anthropic": {
|
||||||
|
// OAuth tokens use Bearer auth instead of x-api-key
|
||||||
|
if (provider.apiKey?.startsWith("sk-ant-oat")) {
|
||||||
|
const oauthToken = provider.apiKey
|
||||||
|
const wrappedFetch: typeof globalThis.fetch = (input, init) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url
|
||||||
|
const betaUrl = url.includes("?")
|
||||||
|
? `${url}&beta=true`
|
||||||
|
: `${url}?beta=true`
|
||||||
|
const existingHeaders =
|
||||||
|
(init?.headers as Record<string, string> | undefined) ?? {}
|
||||||
|
// Remove x-api-key if SDK sets it, add Bearer
|
||||||
|
const { "x-api-key": _dropped, ...rest } = existingHeaders
|
||||||
|
return globalThis.fetch(betaUrl, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...rest,
|
||||||
|
authorization: `Bearer ${oauthToken}`,
|
||||||
|
"anthropic-beta":
|
||||||
|
"oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return new Anthropic({ apiKey: "unused", fetch: wrappedFetch })
|
||||||
|
}
|
||||||
return new Anthropic({
|
return new Anthropic({
|
||||||
apiKey: provider.apiKey,
|
apiKey: provider.apiKey,
|
||||||
...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
|
...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
case "openrouter":
|
case "openrouter":
|
||||||
return new Anthropic({
|
return new Anthropic({
|
||||||
apiKey: provider.apiKey ?? "",
|
apiKey: provider.apiKey ?? "",
|
||||||
|
|||||||
@ -19,3 +19,9 @@ export type {
|
|||||||
DataSource,
|
DataSource,
|
||||||
SSEData,
|
SSEData,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
export {
|
||||||
|
generatePKCE,
|
||||||
|
buildAuthUrl,
|
||||||
|
exchangeCode,
|
||||||
|
refreshAccessToken,
|
||||||
|
} from "./oauth"
|
||||||
|
|||||||
@ -16,6 +16,7 @@ interface AgentOptions {
|
|||||||
readonly tools?: readonly ToolDef[]
|
readonly tools?: readonly ToolDef[]
|
||||||
readonly mcpClientManager?: McpClientManager
|
readonly mcpClientManager?: McpClientManager
|
||||||
readonly maxTurns?: number
|
readonly maxTurns?: number
|
||||||
|
readonly isOAuth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function* runAgent(
|
export async function* runAgent(
|
||||||
@ -66,6 +67,11 @@ export async function* runAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth endpoint requires mcp_ prefix on tool names
|
||||||
|
const effectiveTools: Tool[] = opts.isOAuth
|
||||||
|
? apiTools.map((t) => ({ ...t, name: `mcp_${t.name}` }))
|
||||||
|
: apiTools
|
||||||
|
|
||||||
let turn = 0
|
let turn = 0
|
||||||
|
|
||||||
while (turn < maxTurns) {
|
while (turn < maxTurns) {
|
||||||
@ -77,7 +83,7 @@ export async function* runAgent(
|
|||||||
max_tokens: 8192,
|
max_tokens: 8192,
|
||||||
system: opts.systemPrompt,
|
system: opts.systemPrompt,
|
||||||
messages,
|
messages,
|
||||||
tools: apiTools,
|
tools: effectiveTools,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Stream text deltas and tool_use starts to the caller
|
// Stream text deltas and tool_use starts to the caller
|
||||||
@ -85,9 +91,13 @@ export async function* runAgent(
|
|||||||
if (event.type === "content_block_start") {
|
if (event.type === "content_block_start") {
|
||||||
const block = event.content_block
|
const block = event.content_block
|
||||||
if (block.type === "tool_use") {
|
if (block.type === "tool_use") {
|
||||||
|
const displayName =
|
||||||
|
opts.isOAuth && block.name.startsWith("mcp_")
|
||||||
|
? block.name.slice(4)
|
||||||
|
: block.name
|
||||||
yield {
|
yield {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
name: block.name,
|
name: displayName,
|
||||||
toolCallId: block.id,
|
toolCallId: block.id,
|
||||||
input: {},
|
input: {},
|
||||||
}
|
}
|
||||||
@ -141,12 +151,18 @@ export async function* runAgent(
|
|||||||
for (const block of message.content) {
|
for (const block of message.content) {
|
||||||
if (block.type !== "tool_use") continue
|
if (block.type !== "tool_use") continue
|
||||||
|
|
||||||
const runFn = toolMap.get(block.name)
|
// Strip mcp_ prefix from OAuth tool calls for local dispatch
|
||||||
|
const toolName =
|
||||||
|
opts.isOAuth && block.name.startsWith("mcp_")
|
||||||
|
? block.name.slice(4)
|
||||||
|
: block.name
|
||||||
|
|
||||||
|
const runFn = toolMap.get(toolName)
|
||||||
|
|
||||||
// Route: direct tool -> MCP manager -> unknown
|
// Route: direct tool -> MCP manager -> unknown
|
||||||
if (!runFn && !mcpManager) {
|
if (!runFn && !mcpManager) {
|
||||||
const errorResult = JSON.stringify({
|
const errorResult = JSON.stringify({
|
||||||
error: `Unknown tool: ${block.name}`,
|
error: `Unknown tool: ${toolName}`,
|
||||||
})
|
})
|
||||||
yield {
|
yield {
|
||||||
type: "tool_result",
|
type: "tool_result",
|
||||||
@ -168,12 +184,12 @@ export async function* runAgent(
|
|||||||
result = await runFn(block.input)
|
result = await runFn(block.input)
|
||||||
} else if (mcpManager) {
|
} else if (mcpManager) {
|
||||||
result = await mcpManager.callTool(
|
result = await mcpManager.callTool(
|
||||||
block.name,
|
toolName,
|
||||||
block.input
|
block.input
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
result = JSON.stringify({
|
result = JSON.stringify({
|
||||||
error: `Unknown tool: ${block.name}`,
|
error: `Unknown tool: ${toolName}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
|
|||||||
118
packages/agent-core/src/oauth.ts
Normal file
118
packages/agent-core/src/oauth.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// Anthropic OAuth constants (same as Claude Code / pi-ai)
|
||||||
|
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
||||||
|
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||||
|
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||||
|
const SCOPES = "org:create_api_key user:profile user:inference"
|
||||||
|
|
||||||
|
interface OAuthTokenResponse {
|
||||||
|
readonly access_token: string
|
||||||
|
readonly refresh_token: string
|
||||||
|
readonly expires_in: number
|
||||||
|
readonly token_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ""
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte)
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePKCE(): Promise<{
|
||||||
|
verifier: string
|
||||||
|
challenge: string
|
||||||
|
}> {
|
||||||
|
// 32 random bytes -> 43 base64url chars, well within 43-128 range
|
||||||
|
const verifierBytes = crypto.getRandomValues(new Uint8Array(32))
|
||||||
|
const verifier = base64url(verifierBytes.buffer)
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(verifier)
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||||
|
const challenge = base64url(hash)
|
||||||
|
|
||||||
|
return { verifier, challenge }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthUrl(challenge: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
scope: SCOPES,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
})
|
||||||
|
return `${AUTHORIZE_URL}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenResponse(raw: unknown): {
|
||||||
|
access: string
|
||||||
|
refresh: string
|
||||||
|
expiresAt: number
|
||||||
|
} {
|
||||||
|
if (
|
||||||
|
typeof raw !== "object" ||
|
||||||
|
raw === null ||
|
||||||
|
!("access_token" in raw) ||
|
||||||
|
!("refresh_token" in raw) ||
|
||||||
|
!("expires_in" in raw)
|
||||||
|
) {
|
||||||
|
throw new Error("Unexpected token response shape")
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = raw as OAuthTokenResponse
|
||||||
|
return {
|
||||||
|
access: resp.access_token,
|
||||||
|
refresh: resp.refresh_token,
|
||||||
|
expiresAt: Date.now() + resp.expires_in * 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postToken(body: Record<string, string>): Promise<{
|
||||||
|
access: string
|
||||||
|
refresh: string
|
||||||
|
expiresAt: number
|
||||||
|
}> {
|
||||||
|
const res = await fetch(TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new Error(`Token request failed (${res.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: unknown = await res.json()
|
||||||
|
return parseTokenResponse(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCode(
|
||||||
|
code: string,
|
||||||
|
state: string,
|
||||||
|
verifier: string,
|
||||||
|
): Promise<{ access: string; refresh: string; expiresAt: number }> {
|
||||||
|
return postToken({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
code,
|
||||||
|
state,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
code_verifier: verifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(
|
||||||
|
refreshToken: string,
|
||||||
|
): Promise<{ access: string; refresh: string; expiresAt: number }> {
|
||||||
|
return postToken({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -188,12 +188,16 @@ export async function createAgentStream(
|
|||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isOAuth =
|
||||||
|
provider.apiKey?.startsWith("sk-ant-oat") ?? false
|
||||||
|
|
||||||
const agentStream = runAgent({
|
const agentStream = runAgent({
|
||||||
provider,
|
provider,
|
||||||
model: context.model,
|
model: context.model,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
messages,
|
messages,
|
||||||
mcpClientManager: manager,
|
mcpClientManager: manager,
|
||||||
|
isOAuth,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wrap to disconnect MCP after stream ends
|
// Wrap to disconnect MCP after stream ends
|
||||||
|
|||||||
238
src/app/actions/anthropic-oauth.ts
Normal file
238
src/app/actions/anthropic-oauth.ts
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { getDb } from "@/db"
|
||||||
|
import {
|
||||||
|
anthropicOauthTokens,
|
||||||
|
userProviderConfig,
|
||||||
|
} from "@/db/schema-ai-config"
|
||||||
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
|
import { encrypt, decrypt } from "@/lib/crypto"
|
||||||
|
import { isDemoUser } from "@/lib/demo"
|
||||||
|
import {
|
||||||
|
exchangeCode as exchangeOAuthCode_,
|
||||||
|
refreshAccessToken as refreshToken_,
|
||||||
|
} from "agent-core"
|
||||||
|
|
||||||
|
export async function exchangeOAuthCode(
|
||||||
|
code: string,
|
||||||
|
state: string,
|
||||||
|
verifier: string
|
||||||
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDemoUser(user.id)) {
|
||||||
|
return { success: false, error: "DEMO_READ_ONLY" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await exchangeOAuthCode_(code, state, verifier)
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const encryptionKey = (
|
||||||
|
env as unknown as Record<string, string>
|
||||||
|
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedAccess = await encrypt(
|
||||||
|
tokens.access,
|
||||||
|
encryptionKey,
|
||||||
|
user.id
|
||||||
|
)
|
||||||
|
const encryptedRefresh = await encrypt(
|
||||||
|
tokens.refresh,
|
||||||
|
encryptionKey,
|
||||||
|
user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const expiresAt = new Date(tokens.expiresAt).toISOString()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(anthropicOauthTokens)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
accessToken: encryptedAccess,
|
||||||
|
refreshToken: encryptedRefresh,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: anthropicOauthTokens.userId,
|
||||||
|
set: {
|
||||||
|
accessToken: encryptedAccess,
|
||||||
|
refreshToken: encryptedRefresh,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(userProviderConfig)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
providerType: "anthropic-oauth",
|
||||||
|
apiKey: null,
|
||||||
|
baseUrl: null,
|
||||||
|
modelOverrides: null,
|
||||||
|
isActive: 1,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: userProviderConfig.userId,
|
||||||
|
set: {
|
||||||
|
providerType: "anthropic-oauth",
|
||||||
|
apiKey: null,
|
||||||
|
baseUrl: null,
|
||||||
|
isActive: 1,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
revalidatePath("/dashboard")
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to exchange OAuth code",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a valid access token for the given userId, refreshing if needed.
|
||||||
|
// Returns null if no token exists or on any error.
|
||||||
|
export async function getOAuthAccessToken(
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const row = await db
|
||||||
|
.select()
|
||||||
|
.from(anthropicOauthTokens)
|
||||||
|
.where(eq(anthropicOauthTokens.userId, userId))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
const encryptionKey = (
|
||||||
|
env as unknown as Record<string, string>
|
||||||
|
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
if (!encryptionKey) return null
|
||||||
|
|
||||||
|
const isExpired =
|
||||||
|
Date.now() >
|
||||||
|
new Date(row.expiresAt).getTime() - 5 * 60 * 1000
|
||||||
|
|
||||||
|
if (!isExpired) {
|
||||||
|
return await decrypt(row.accessToken, encryptionKey, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expired — refresh
|
||||||
|
const refreshToken = await decrypt(
|
||||||
|
row.refreshToken,
|
||||||
|
encryptionKey,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
const fresh = await refreshToken_(refreshToken)
|
||||||
|
|
||||||
|
const encryptedAccess = await encrypt(
|
||||||
|
fresh.access,
|
||||||
|
encryptionKey,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
const encryptedRefresh = await encrypt(
|
||||||
|
fresh.refresh,
|
||||||
|
encryptionKey,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const expiresAt = new Date(fresh.expiresAt).toISOString()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(anthropicOauthTokens)
|
||||||
|
.set({
|
||||||
|
accessToken: encryptedAccess,
|
||||||
|
refreshToken: encryptedRefresh,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(anthropicOauthTokens.userId, userId))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
return fresh.access
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectOAuth(): Promise<{
|
||||||
|
success: true
|
||||||
|
}> {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
// Still return success shape — nothing to disconnect
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(anthropicOauthTokens)
|
||||||
|
.where(eq(anthropicOauthTokens.userId, user.id))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(userProviderConfig)
|
||||||
|
.where(eq(userProviderConfig.userId, user.id))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
revalidatePath("/dashboard")
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOAuthStatus(): Promise<{
|
||||||
|
connected: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return { connected: false }
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const row = await db
|
||||||
|
.select()
|
||||||
|
.from(anthropicOauthTokens)
|
||||||
|
.where(eq(anthropicOauthTokens.userId, user.id))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!row) return { connected: false }
|
||||||
|
|
||||||
|
return { connected: true, expiresAt: row.expiresAt }
|
||||||
|
} catch {
|
||||||
|
return { connected: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -112,20 +112,21 @@ export async function getProviderConfigForJwt(
|
|||||||
env as unknown as Record<string, string>
|
env as unknown as Record<string, string>
|
||||||
).PROVIDER_KEY_ENCRYPTION_KEY
|
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||||
|
|
||||||
if (!encryptionKey) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let decryptedApiKey: string | null = null
|
let decryptedApiKey: string | null = null
|
||||||
if (config.apiKey) {
|
if (config.apiKey) {
|
||||||
try {
|
if (!encryptionKey) {
|
||||||
decryptedApiKey = await decrypt(
|
// Can't decrypt, but still return the config without a key
|
||||||
config.apiKey,
|
|
||||||
encryptionKey,
|
|
||||||
userId
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
decryptedApiKey = null
|
decryptedApiKey = null
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
decryptedApiKey = await decrypt(
|
||||||
|
config.apiKey,
|
||||||
|
encryptionKey,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
decryptedApiKey = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,20 +190,19 @@ export async function setUserProviderConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptionKey = (
|
|
||||||
env as unknown as Record<string, string>
|
|
||||||
).PROVIDER_KEY_ENCRYPTION_KEY
|
|
||||||
|
|
||||||
if (!encryptionKey) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let encryptedApiKey: string | null = null
|
let encryptedApiKey: string | null = null
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
|
const encryptionKey = (
|
||||||
|
env as unknown as Record<string, string>
|
||||||
|
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
|
||||||
|
}
|
||||||
|
}
|
||||||
encryptedApiKey = await encrypt(
|
encryptedApiKey = await encrypt(
|
||||||
apiKey,
|
apiKey,
|
||||||
encryptionKey,
|
encryptionKey,
|
||||||
@ -376,20 +376,19 @@ export async function setOrgProviderConfig(
|
|||||||
const { env } = await getCloudflareContext()
|
const { env } = await getCloudflareContext()
|
||||||
const db = getDb(env.DB)
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
const encryptionKey = (
|
|
||||||
env as unknown as Record<string, string>
|
|
||||||
).PROVIDER_KEY_ENCRYPTION_KEY
|
|
||||||
|
|
||||||
if (!encryptionKey) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let encryptedApiKey: string | null = null
|
let encryptedApiKey: string | null = null
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
|
const encryptionKey = (
|
||||||
|
env as unknown as Record<string, string>
|
||||||
|
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
|
||||||
|
}
|
||||||
|
}
|
||||||
encryptedApiKey = await encrypt(
|
encryptedApiKey = await encrypt(
|
||||||
apiKey,
|
apiKey,
|
||||||
encryptionKey,
|
encryptionKey,
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
import { getProviderConfigForJwt } from "@/app/actions/provider-config"
|
import { getProviderConfigForJwt } from "@/app/actions/provider-config"
|
||||||
|
import { getOAuthAccessToken } from "@/app/actions/anthropic-oauth"
|
||||||
import { generateAgentToken } from "@/lib/agent/api-auth"
|
import { generateAgentToken } from "@/lib/agent/api-auth"
|
||||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
import { getDb } from "@/db"
|
import { getDb } from "@/db"
|
||||||
@ -107,7 +108,7 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider: ProviderConfig = providerConfig
|
let provider: ProviderConfig = providerConfig
|
||||||
? {
|
? {
|
||||||
type: mapProviderType(providerConfig.type),
|
type: mapProviderType(providerConfig.type),
|
||||||
apiKey: providerConfig.apiKey ?? undefined,
|
apiKey: providerConfig.apiKey ?? undefined,
|
||||||
@ -117,6 +118,26 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
: { type: "anthropic" }
|
: { type: "anthropic" }
|
||||||
|
|
||||||
|
// Resolve OAuth access token if needed
|
||||||
|
if (providerConfig?.type === "anthropic-oauth") {
|
||||||
|
const accessToken = await getOAuthAccessToken(user.id)
|
||||||
|
if (!accessToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Anthropic OAuth not connected or token expired",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
provider = {
|
||||||
|
type: "anthropic",
|
||||||
|
apiKey: accessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate JWT for bridge route auth
|
// Generate JWT for bridge route auth
|
||||||
const { env } = await getCloudflareContext()
|
const { env } = await getCloudflareContext()
|
||||||
const envRecord = env as unknown as Record<
|
const envRecord = env as unknown as Record<
|
||||||
@ -261,12 +282,16 @@ export async function POST(
|
|||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isOAuth =
|
||||||
|
provider.apiKey?.startsWith("sk-ant-oat") ?? false
|
||||||
|
|
||||||
const stream = runAgent({
|
const stream = runAgent({
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
messages: msgs,
|
messages: msgs,
|
||||||
mcpClientManager: manager,
|
mcpClientManager: manager,
|
||||||
|
isOAuth,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wrap stream to disconnect MCP after completion
|
// Wrap stream to disconnect MCP after completion
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
Eye,
|
Eye,
|
||||||
@ -47,6 +48,15 @@ import {
|
|||||||
setUserProviderConfig,
|
setUserProviderConfig,
|
||||||
clearUserProviderConfig,
|
clearUserProviderConfig,
|
||||||
} from "@/app/actions/provider-config"
|
} from "@/app/actions/provider-config"
|
||||||
|
import {
|
||||||
|
exchangeOAuthCode,
|
||||||
|
disconnectOAuth,
|
||||||
|
getOAuthStatus,
|
||||||
|
} from "@/app/actions/anthropic-oauth"
|
||||||
|
import {
|
||||||
|
generatePKCE,
|
||||||
|
buildAuthUrl,
|
||||||
|
} from "@/lib/anthropic-oauth-client"
|
||||||
import { Slider } from "@/components/ui/slider"
|
import { Slider } from "@/components/ui/slider"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@ -224,6 +234,11 @@ function outputCostPerMillion(
|
|||||||
// Provider Configuration Section
|
// Provider Configuration Section
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
type OAuthState =
|
||||||
|
| { step: "idle" }
|
||||||
|
| { step: "connecting"; verifier: string }
|
||||||
|
| { step: "connected"; expiresAt?: string }
|
||||||
|
|
||||||
function ProviderConfigSection({
|
function ProviderConfigSection({
|
||||||
onProviderChanged,
|
onProviderChanged,
|
||||||
}: {
|
}: {
|
||||||
@ -239,16 +254,25 @@ function ProviderConfigSection({
|
|||||||
const [hasStoredKey, setHasStoredKey] =
|
const [hasStoredKey, setHasStoredKey] =
|
||||||
React.useState(false)
|
React.useState(false)
|
||||||
|
|
||||||
// load current config from D1
|
// OAuth state
|
||||||
|
const [oauth, setOAuth] = React.useState<OAuthState>({
|
||||||
|
step: "idle",
|
||||||
|
})
|
||||||
|
const [oauthCode, setOAuthCode] = React.useState("")
|
||||||
|
|
||||||
|
// load current config + OAuth status from D1
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
getUserProviderConfig()
|
Promise.all([
|
||||||
.then((result) => {
|
getUserProviderConfig(),
|
||||||
|
getOAuthStatus(),
|
||||||
|
])
|
||||||
|
.then(([configResult, oauthStatus]) => {
|
||||||
if (
|
if (
|
||||||
"success" in result &&
|
"success" in configResult &&
|
||||||
result.success &&
|
configResult.success &&
|
||||||
result.data
|
configResult.data
|
||||||
) {
|
) {
|
||||||
const d = result.data
|
const d = configResult.data
|
||||||
const type = (
|
const type = (
|
||||||
PROVIDER_TYPES.includes(
|
PROVIDER_TYPES.includes(
|
||||||
d.providerType as ProviderType
|
d.providerType as ProviderType
|
||||||
@ -260,6 +284,12 @@ function ProviderConfigSection({
|
|||||||
setBaseUrl(d.baseUrl ?? "")
|
setBaseUrl(d.baseUrl ?? "")
|
||||||
setHasStoredKey(d.hasApiKey)
|
setHasStoredKey(d.hasApiKey)
|
||||||
}
|
}
|
||||||
|
if (oauthStatus.connected) {
|
||||||
|
setOAuth({
|
||||||
|
step: "connected",
|
||||||
|
expiresAt: oauthStatus.expiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@ -275,6 +305,10 @@ function ProviderConfigSection({
|
|||||||
setActiveType(type)
|
setActiveType(type)
|
||||||
setApiKey("")
|
setApiKey("")
|
||||||
setShowKey(false)
|
setShowKey(false)
|
||||||
|
setOAuthCode("")
|
||||||
|
if (type !== "anthropic-oauth") {
|
||||||
|
setOAuth({ step: "idle" })
|
||||||
|
}
|
||||||
const newInfo = PROVIDERS.find(
|
const newInfo = PROVIDERS.find(
|
||||||
(p) => p.type === type
|
(p) => p.type === type
|
||||||
)
|
)
|
||||||
@ -282,6 +316,54 @@ function ProviderConfigSection({
|
|||||||
setHasStoredKey(false)
|
setHasStoredKey(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOAuthConnect = async (): Promise<void> => {
|
||||||
|
const { verifier, challenge } = await generatePKCE()
|
||||||
|
const url = buildAuthUrl(challenge)
|
||||||
|
setOAuth({ step: "connecting", verifier })
|
||||||
|
window.open(url, "_blank", "noopener")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOAuthSubmit = async (): Promise<void> => {
|
||||||
|
if (oauth.step !== "connecting") return
|
||||||
|
const trimmed = oauthCode.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
|
||||||
|
// Parse "code#state" or just "code"
|
||||||
|
const hashIdx = trimmed.indexOf("#")
|
||||||
|
const code =
|
||||||
|
hashIdx >= 0 ? trimmed.slice(0, hashIdx) : trimmed
|
||||||
|
const state =
|
||||||
|
hashIdx >= 0 ? trimmed.slice(hashIdx + 1) : ""
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
const result = await exchangeOAuthCode(
|
||||||
|
code,
|
||||||
|
state,
|
||||||
|
oauth.verifier
|
||||||
|
)
|
||||||
|
setSaving(false)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Connected to Anthropic")
|
||||||
|
setOAuth({ step: "connected" })
|
||||||
|
setOAuthCode("")
|
||||||
|
setProviderType("anthropic-oauth")
|
||||||
|
onProviderChanged()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "OAuth failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOAuthDisconnect =
|
||||||
|
async (): Promise<void> => {
|
||||||
|
setSaving(true)
|
||||||
|
await disconnectOAuth()
|
||||||
|
setSaving(false)
|
||||||
|
setOAuth({ step: "idle" })
|
||||||
|
toast.success("Disconnected")
|
||||||
|
onProviderChanged()
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const result = await setUserProviderConfig(
|
const result = await setUserProviderConfig(
|
||||||
@ -375,91 +457,184 @@ function ProviderConfigSection({
|
|||||||
{info.description}
|
{info.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* credential inputs */}
|
{/* OAuth flow for anthropic-oauth */}
|
||||||
{(info.needsApiKey || info.needsBaseUrl) && (
|
{activeType === "anthropic-oauth" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{info.needsApiKey && (
|
{oauth.step === "connected" && (
|
||||||
<div className="space-y-1">
|
<div className="flex items-center justify-between rounded-md border border-green-500/20 bg-green-500/5 px-3 py-2">
|
||||||
<Label className="text-[11px]">
|
<div className="flex items-center gap-2">
|
||||||
API Key
|
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||||
</Label>
|
<span className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||||
<div className="relative">
|
Connected
|
||||||
<Input
|
</span>
|
||||||
type={showKey ? "text" : "password"}
|
{oauth.expiresAt && (
|
||||||
value={apiKey}
|
<span className="text-[10px] text-muted-foreground">
|
||||||
onChange={(e) =>
|
expires{" "}
|
||||||
setApiKey(e.target.value)
|
{new Date(
|
||||||
}
|
oauth.expiresAt
|
||||||
placeholder={
|
).toLocaleDateString()}
|
||||||
hasStoredKey
|
</span>
|
||||||
? "Key saved (enter new to replace)"
|
)}
|
||||||
: activeType === "openrouter"
|
|
||||||
? "sk-or-..."
|
|
||||||
: activeType === "anthropic-key"
|
|
||||||
? "sk-ant-..."
|
|
||||||
: "API key"
|
|
||||||
}
|
|
||||||
className="h-8 pr-10 text-xs"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setShowKey((v) => !v)
|
|
||||||
}
|
|
||||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label={
|
|
||||||
showKey ? "Hide key" : "Show key"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showKey ? (
|
|
||||||
<EyeOff className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||||
|
onClick={handleOAuthDisconnect}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{info.needsBaseUrl && (
|
{oauth.step === "idle" && (
|
||||||
<div className="space-y-1">
|
<Button
|
||||||
<Label className="text-[11px]">
|
size="sm"
|
||||||
Base URL
|
className="h-8"
|
||||||
</Label>
|
onClick={handleOAuthConnect}
|
||||||
<Input
|
>
|
||||||
type="text"
|
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||||
value={
|
Connect with Anthropic
|
||||||
baseUrl || info.defaultBaseUrl || ""
|
</Button>
|
||||||
}
|
)}
|
||||||
onChange={(e) =>
|
|
||||||
setBaseUrl(e.target.value)
|
{oauth.step === "connecting" && (
|
||||||
}
|
<div className="space-y-2">
|
||||||
placeholder={
|
<p className="text-[11px] text-muted-foreground">
|
||||||
info.defaultBaseUrl ?? "https://..."
|
Authorize in the browser tab that opened,
|
||||||
}
|
then paste the code below.
|
||||||
className="h-8 text-xs"
|
</p>
|
||||||
/>
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={oauthCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOAuthCode(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Paste authorization code here"
|
||||||
|
className="h-8 text-xs font-mono"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
onClick={handleOAuthSubmit}
|
||||||
|
disabled={saving || !oauthCode.trim()}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Submit"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
setOAuth({ step: "idle" })
|
||||||
|
setOAuthCode("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* actions */}
|
{/* credential inputs (non-OAuth providers) */}
|
||||||
<div className="flex items-center gap-2">
|
{activeType !== "anthropic-oauth" &&
|
||||||
<Button
|
(info.needsApiKey || info.needsBaseUrl) && (
|
||||||
size="sm"
|
<div className="space-y-2">
|
||||||
className="h-8"
|
{info.needsApiKey && (
|
||||||
onClick={handleSave}
|
<div className="space-y-1">
|
||||||
disabled={saving}
|
<Label className="text-[11px]">
|
||||||
>
|
API Key
|
||||||
{saving && (
|
</Label>
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
<div className="relative">
|
||||||
)}
|
<Input
|
||||||
Save Provider
|
type={showKey ? "text" : "password"}
|
||||||
</Button>
|
value={apiKey}
|
||||||
{activeType !== "anthropic-oauth" && (
|
onChange={(e) =>
|
||||||
|
setApiKey(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
hasStoredKey
|
||||||
|
? "Key saved (enter new to replace)"
|
||||||
|
: activeType === "openrouter"
|
||||||
|
? "sk-or-..."
|
||||||
|
: activeType === "anthropic-key"
|
||||||
|
? "sk-ant-..."
|
||||||
|
: "API key"
|
||||||
|
}
|
||||||
|
className="h-8 pr-10 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center pr-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setShowKey((v) => !v)
|
||||||
|
}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={
|
||||||
|
showKey
|
||||||
|
? "Hide key"
|
||||||
|
: "Show key"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showKey ? (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{info.needsBaseUrl && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[11px]">
|
||||||
|
Base URL
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={
|
||||||
|
baseUrl ||
|
||||||
|
info.defaultBaseUrl ||
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBaseUrl(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
info.defaultBaseUrl ?? "https://..."
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* actions (non-OAuth providers) */}
|
||||||
|
{activeType !== "anthropic-oauth" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving && (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
Save Provider
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -470,8 +645,8 @@ function ProviderConfigSection({
|
|||||||
<X className="mr-1 h-3 w-3" />
|
<X className="mr-1 h-3 w-3" />
|
||||||
Reset to default
|
Reset to default
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,21 @@ export const agentUsage = sqliteTable("agent_usage", {
|
|||||||
createdAt: text("created_at").notNull(),
|
createdAt: text("created_at").notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// per-user Anthropic OAuth tokens (separate from provider config
|
||||||
|
// because OAuth needs refresh token + expiry tracking)
|
||||||
|
export const anthropicOauthTokens = sqliteTable(
|
||||||
|
"anthropic_oauth_tokens",
|
||||||
|
{
|
||||||
|
userId: text("user_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: text("access_token").notNull(),
|
||||||
|
refreshToken: text("refresh_token").notNull(),
|
||||||
|
expiresAt: text("expires_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export type AgentConfig = typeof agentConfig.$inferSelect
|
export type AgentConfig = typeof agentConfig.$inferSelect
|
||||||
export type NewAgentConfig = typeof agentConfig.$inferInsert
|
export type NewAgentConfig = typeof agentConfig.$inferInsert
|
||||||
export type UserProviderConfig = typeof userProviderConfig.$inferSelect
|
export type UserProviderConfig = typeof userProviderConfig.$inferSelect
|
||||||
@ -89,3 +104,7 @@ export type UserModelPreference =
|
|||||||
typeof userModelPreference.$inferSelect
|
typeof userModelPreference.$inferSelect
|
||||||
export type NewUserModelPreference =
|
export type NewUserModelPreference =
|
||||||
typeof userModelPreference.$inferInsert
|
typeof userModelPreference.$inferInsert
|
||||||
|
export type AnthropicOauthToken =
|
||||||
|
typeof anthropicOauthTokens.$inferSelect
|
||||||
|
export type NewAnthropicOauthToken =
|
||||||
|
typeof anthropicOauthTokens.$inferInsert
|
||||||
|
|||||||
52
src/lib/anthropic-oauth-client.ts
Normal file
52
src/lib/anthropic-oauth-client.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// Browser-safe PKCE + auth URL generation for Anthropic OAuth.
|
||||||
|
// Duplicated from agent-core/oauth.ts to avoid pulling in
|
||||||
|
// server-only MCP deps via the barrel export.
|
||||||
|
|
||||||
|
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
||||||
|
const REDIRECT_URI =
|
||||||
|
"https://console.anthropic.com/oauth/code/callback"
|
||||||
|
const SCOPES = "org:create_api_key user:profile user:inference"
|
||||||
|
|
||||||
|
function base64url(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ""
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte)
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePKCE(): Promise<{
|
||||||
|
verifier: string
|
||||||
|
challenge: string
|
||||||
|
}> {
|
||||||
|
const verifierBytes = crypto.getRandomValues(
|
||||||
|
new Uint8Array(32)
|
||||||
|
)
|
||||||
|
const verifier = base64url(verifierBytes.buffer)
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const hash = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
encoder.encode(verifier)
|
||||||
|
)
|
||||||
|
const challenge = base64url(hash)
|
||||||
|
|
||||||
|
return { verifier, challenge }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthUrl(challenge: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
scope: SCOPES,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
})
|
||||||
|
return `${AUTHORIZE_URL}?${params.toString()}`
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user