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,
|
||||
"tag": "0028_small_old_lace",
|
||||
"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 {
|
||||
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({
|
||||
apiKey: provider.apiKey,
|
||||
...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
|
||||
})
|
||||
}
|
||||
case "openrouter":
|
||||
return new Anthropic({
|
||||
apiKey: provider.apiKey ?? "",
|
||||
|
||||
@ -19,3 +19,9 @@ export type {
|
||||
DataSource,
|
||||
SSEData,
|
||||
} from "./types"
|
||||
export {
|
||||
generatePKCE,
|
||||
buildAuthUrl,
|
||||
exchangeCode,
|
||||
refreshAccessToken,
|
||||
} from "./oauth"
|
||||
|
||||
@ -16,6 +16,7 @@ interface AgentOptions {
|
||||
readonly tools?: readonly ToolDef[]
|
||||
readonly mcpClientManager?: McpClientManager
|
||||
readonly maxTurns?: number
|
||||
readonly isOAuth?: boolean
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
while (turn < maxTurns) {
|
||||
@ -77,7 +83,7 @@ export async function* runAgent(
|
||||
max_tokens: 8192,
|
||||
system: opts.systemPrompt,
|
||||
messages,
|
||||
tools: apiTools,
|
||||
tools: effectiveTools,
|
||||
})
|
||||
|
||||
// Stream text deltas and tool_use starts to the caller
|
||||
@ -85,9 +91,13 @@ export async function* runAgent(
|
||||
if (event.type === "content_block_start") {
|
||||
const block = event.content_block
|
||||
if (block.type === "tool_use") {
|
||||
const displayName =
|
||||
opts.isOAuth && block.name.startsWith("mcp_")
|
||||
? block.name.slice(4)
|
||||
: block.name
|
||||
yield {
|
||||
type: "tool_use",
|
||||
name: block.name,
|
||||
name: displayName,
|
||||
toolCallId: block.id,
|
||||
input: {},
|
||||
}
|
||||
@ -141,12 +151,18 @@ export async function* runAgent(
|
||||
for (const block of message.content) {
|
||||
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
|
||||
if (!runFn && !mcpManager) {
|
||||
const errorResult = JSON.stringify({
|
||||
error: `Unknown tool: ${block.name}`,
|
||||
error: `Unknown tool: ${toolName}`,
|
||||
})
|
||||
yield {
|
||||
type: "tool_result",
|
||||
@ -168,12 +184,12 @@ export async function* runAgent(
|
||||
result = await runFn(block.input)
|
||||
} else if (mcpManager) {
|
||||
result = await mcpManager.callTool(
|
||||
block.name,
|
||||
toolName,
|
||||
block.input
|
||||
)
|
||||
} else {
|
||||
result = JSON.stringify({
|
||||
error: `Unknown tool: ${block.name}`,
|
||||
error: `Unknown tool: ${toolName}`,
|
||||
})
|
||||
}
|
||||
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,
|
||||
})
|
||||
|
||||
const isOAuth =
|
||||
provider.apiKey?.startsWith("sk-ant-oat") ?? false
|
||||
|
||||
const agentStream = runAgent({
|
||||
provider,
|
||||
model: context.model,
|
||||
systemPrompt,
|
||||
messages,
|
||||
mcpClientManager: manager,
|
||||
isOAuth,
|
||||
})
|
||||
|
||||
// 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,12 +112,12 @@ export async function getProviderConfigForJwt(
|
||||
env as unknown as Record<string, string>
|
||||
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||
|
||||
if (!encryptionKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
let decryptedApiKey: string | null = null
|
||||
if (config.apiKey) {
|
||||
if (!encryptionKey) {
|
||||
// Can't decrypt, but still return the config without a key
|
||||
decryptedApiKey = null
|
||||
} else {
|
||||
try {
|
||||
decryptedApiKey = await decrypt(
|
||||
config.apiKey,
|
||||
@ -128,6 +128,7 @@ export async function getProviderConfigForJwt(
|
||||
decryptedApiKey = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let modelOverrides: Record<string, string> | null = null
|
||||
if (config.modelOverrides) {
|
||||
@ -189,6 +190,8 @@ export async function setUserProviderConfig(
|
||||
}
|
||||
}
|
||||
|
||||
let encryptedApiKey: string | null = null
|
||||
if (apiKey) {
|
||||
const encryptionKey = (
|
||||
env as unknown as Record<string, string>
|
||||
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||
@ -200,9 +203,6 @@ export async function setUserProviderConfig(
|
||||
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
|
||||
}
|
||||
}
|
||||
|
||||
let encryptedApiKey: string | null = null
|
||||
if (apiKey) {
|
||||
encryptedApiKey = await encrypt(
|
||||
apiKey,
|
||||
encryptionKey,
|
||||
@ -376,6 +376,8 @@ export async function setOrgProviderConfig(
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
let encryptedApiKey: string | null = null
|
||||
if (apiKey) {
|
||||
const encryptionKey = (
|
||||
env as unknown as Record<string, string>
|
||||
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||
@ -387,9 +389,6 @@ export async function setOrgProviderConfig(
|
||||
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
|
||||
}
|
||||
}
|
||||
|
||||
let encryptedApiKey: string | null = null
|
||||
if (apiKey) {
|
||||
encryptedApiKey = await encrypt(
|
||||
apiKey,
|
||||
encryptionKey,
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getProviderConfigForJwt } from "@/app/actions/provider-config"
|
||||
import { getOAuthAccessToken } from "@/app/actions/anthropic-oauth"
|
||||
import { generateAgentToken } from "@/lib/agent/api-auth"
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
import { getDb } from "@/db"
|
||||
@ -107,7 +108,7 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
const provider: ProviderConfig = providerConfig
|
||||
let provider: ProviderConfig = providerConfig
|
||||
? {
|
||||
type: mapProviderType(providerConfig.type),
|
||||
apiKey: providerConfig.apiKey ?? undefined,
|
||||
@ -117,6 +118,26 @@ export async function POST(
|
||||
}
|
||||
: { 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
|
||||
const { env } = await getCloudflareContext()
|
||||
const envRecord = env as unknown as Record<
|
||||
@ -261,12 +282,16 @@ export async function POST(
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const isOAuth =
|
||||
provider.apiKey?.startsWith("sk-ant-oat") ?? false
|
||||
|
||||
const stream = runAgent({
|
||||
provider,
|
||||
model,
|
||||
systemPrompt,
|
||||
messages: msgs,
|
||||
mcpClientManager: manager,
|
||||
isOAuth,
|
||||
})
|
||||
|
||||
// Wrap stream to disconnect MCP after completion
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Search,
|
||||
Eye,
|
||||
@ -47,6 +48,15 @@ import {
|
||||
setUserProviderConfig,
|
||||
clearUserProviderConfig,
|
||||
} 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 { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -224,6 +234,11 @@ function outputCostPerMillion(
|
||||
// Provider Configuration Section
|
||||
// ============================================================================
|
||||
|
||||
type OAuthState =
|
||||
| { step: "idle" }
|
||||
| { step: "connecting"; verifier: string }
|
||||
| { step: "connected"; expiresAt?: string }
|
||||
|
||||
function ProviderConfigSection({
|
||||
onProviderChanged,
|
||||
}: {
|
||||
@ -239,16 +254,25 @@ function ProviderConfigSection({
|
||||
const [hasStoredKey, setHasStoredKey] =
|
||||
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(() => {
|
||||
getUserProviderConfig()
|
||||
.then((result) => {
|
||||
Promise.all([
|
||||
getUserProviderConfig(),
|
||||
getOAuthStatus(),
|
||||
])
|
||||
.then(([configResult, oauthStatus]) => {
|
||||
if (
|
||||
"success" in result &&
|
||||
result.success &&
|
||||
result.data
|
||||
"success" in configResult &&
|
||||
configResult.success &&
|
||||
configResult.data
|
||||
) {
|
||||
const d = result.data
|
||||
const d = configResult.data
|
||||
const type = (
|
||||
PROVIDER_TYPES.includes(
|
||||
d.providerType as ProviderType
|
||||
@ -260,6 +284,12 @@ function ProviderConfigSection({
|
||||
setBaseUrl(d.baseUrl ?? "")
|
||||
setHasStoredKey(d.hasApiKey)
|
||||
}
|
||||
if (oauthStatus.connected) {
|
||||
setOAuth({
|
||||
step: "connected",
|
||||
expiresAt: oauthStatus.expiresAt,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
@ -275,6 +305,10 @@ function ProviderConfigSection({
|
||||
setActiveType(type)
|
||||
setApiKey("")
|
||||
setShowKey(false)
|
||||
setOAuthCode("")
|
||||
if (type !== "anthropic-oauth") {
|
||||
setOAuth({ step: "idle" })
|
||||
}
|
||||
const newInfo = PROVIDERS.find(
|
||||
(p) => p.type === type
|
||||
)
|
||||
@ -282,6 +316,54 @@ function ProviderConfigSection({
|
||||
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> => {
|
||||
setSaving(true)
|
||||
const result = await setUserProviderConfig(
|
||||
@ -375,8 +457,97 @@ function ProviderConfigSection({
|
||||
{info.description}
|
||||
</p>
|
||||
|
||||
{/* credential inputs */}
|
||||
{(info.needsApiKey || info.needsBaseUrl) && (
|
||||
{/* OAuth flow for anthropic-oauth */}
|
||||
{activeType === "anthropic-oauth" && (
|
||||
<div className="space-y-2">
|
||||
{oauth.step === "connected" && (
|
||||
<div className="flex items-center justify-between rounded-md border border-green-500/20 bg-green-500/5 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
Connected
|
||||
</span>
|
||||
{oauth.expiresAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
expires{" "}
|
||||
{new Date(
|
||||
oauth.expiresAt
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
onClick={handleOAuthDisconnect}
|
||||
disabled={saving}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauth.step === "idle" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleOAuthConnect}
|
||||
>
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
Connect with Anthropic
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{oauth.step === "connecting" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Authorize in the browser tab that opened,
|
||||
then paste the code below.
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* credential inputs (non-OAuth providers) */}
|
||||
{activeType !== "anthropic-oauth" &&
|
||||
(info.needsApiKey || info.needsBaseUrl) && (
|
||||
<div className="space-y-2">
|
||||
{info.needsApiKey && (
|
||||
<div className="space-y-1">
|
||||
@ -409,7 +580,9 @@ function ProviderConfigSection({
|
||||
}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showKey ? "Hide key" : "Show key"
|
||||
showKey
|
||||
? "Hide key"
|
||||
: "Show key"
|
||||
}
|
||||
>
|
||||
{showKey ? (
|
||||
@ -431,7 +604,9 @@ function ProviderConfigSection({
|
||||
<Input
|
||||
type="text"
|
||||
value={
|
||||
baseUrl || info.defaultBaseUrl || ""
|
||||
baseUrl ||
|
||||
info.defaultBaseUrl ||
|
||||
""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setBaseUrl(e.target.value)
|
||||
@ -446,7 +621,8 @@ function ProviderConfigSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* actions */}
|
||||
{/* actions (non-OAuth providers) */}
|
||||
{activeType !== "anthropic-oauth" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@ -459,7 +635,6 @@ function ProviderConfigSection({
|
||||
)}
|
||||
Save Provider
|
||||
</Button>
|
||||
{activeType !== "anthropic-oauth" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@ -470,8 +645,8 @@ function ProviderConfigSection({
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Reset to default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -79,6 +79,21 @@ export const agentUsage = sqliteTable("agent_usage", {
|
||||
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 NewAgentConfig = typeof agentConfig.$inferInsert
|
||||
export type UserProviderConfig = typeof userProviderConfig.$inferSelect
|
||||
@ -89,3 +104,7 @@ export type UserModelPreference =
|
||||
typeof userModelPreference.$inferSelect
|
||||
export type NewUserModelPreference =
|
||||
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