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:
Nicholai Vogel 2026-02-16 22:05:01 -07:00
parent 3f8d273986
commit c53f3a5fac
14 changed files with 6227 additions and 127 deletions

View 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
);

File diff suppressed because it is too large Load Diff

View File

@ -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
}
]
}

View File

@ -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 ?? "",

View File

@ -19,3 +19,9 @@ export type {
DataSource,
SSEData,
} from "./types"
export {
generatePKCE,
buildAuthUrl,
exchangeCode,
refreshAccessToken,
} from "./oauth"

View File

@ -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

View 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,
})
}

View File

@ -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

View 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 }
}
}

View File

@ -112,20 +112,21 @@ 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) {
try {
decryptedApiKey = await decrypt(
config.apiKey,
encryptionKey,
userId
)
} catch {
if (!encryptionKey) {
// Can't decrypt, but still return the config without a key
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
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(
apiKey,
encryptionKey,
@ -376,20 +376,19 @@ export async function setOrgProviderConfig(
const { env } = await getCloudflareContext()
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
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(
apiKey,
encryptionKey,

View File

@ -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

View File

@ -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,91 +457,184 @@ function ProviderConfigSection({
{info.description}
</p>
{/* credential inputs */}
{(info.needsApiKey || info.needsBaseUrl) && (
{/* OAuth flow for anthropic-oauth */}
{activeType === "anthropic-oauth" && (
<div className="space-y-2">
{info.needsApiKey && (
<div className="space-y-1">
<Label className="text-[11px]">
API Key
</Label>
<div className="relative">
<Input
type={showKey ? "text" : "password"}
value={apiKey}
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>
{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>
)}
{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"
/>
{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>
)}
{/* actions */}
<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>
{activeType !== "anthropic-oauth" && (
{/* credential inputs (non-OAuth providers) */}
{activeType !== "anthropic-oauth" &&
(info.needsApiKey || info.needsBaseUrl) && (
<div className="space-y-2">
{info.needsApiKey && (
<div className="space-y-1">
<Label className="text-[11px]">
API Key
</Label>
<div className="relative">
<Input
type={showKey ? "text" : "password"}
value={apiKey}
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
size="sm"
variant="ghost"
@ -470,8 +645,8 @@ function ProviderConfigSection({
<X className="mr-1 h-3 w-3" />
Reset to default
</Button>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -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

View 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()}`
}