Skip to main content

Authorize tool calls

Gate individual eve tool calls against the caller's Clerk permissions and scopes — and broker OAuth for tools that need a provider token on the caller's behalf.

Tip

Skip the OAuth boilerplate — install the clerkConnect() and clerkOAuthToken() helpers via shadcn.

terminal
bunx --bun shadcn@latest add clerk/eve-agents/auth

The manual path has two parts: permission checks inside a tool, then OAuth brokered through Clerk.

Permission checks inside a tool

Read the authenticated principal off eve's session context and, if the caller is a user, check their permissions. See Custom channel auth for how permissions and scopes get populated.

Factor the guards into helpers so the tool reads as a sequence of small checks. requireUserPrincipal returns an agent error if the caller isn't a signed-in user; requirePermissions returns one if any of the required permissions are missing.

lib/agent-errors.ts
import type { SessionAuthContext } from 'eve/context'

export type AgentError = { error: true; message: string }

export function requireUserPrincipal(
  auth: SessionAuthContext | null,
  message: string,
): AgentError | null {
  if (auth?.principalType !== 'user') {
    return { error: true, message }
  }
  return null
}

export function requirePermissions(
  auth: SessionAuthContext | null,
  required: readonly string[],
): AgentError | null {
  const granted = auth?.attributes.permissions
  const missing = Array.isArray(granted) ? required.filter((p) => !granted.includes(p)) : required
  if (missing.length === 0) return null
  return {
    error: true,
    message: `Missing required permission: ${missing.join(', ')}.`,
  }
}
agent/tools/archive_project.ts
import { defineTool } from 'eve/tools'
import { z } from 'zod'
import { requirePermissions, requireUserPrincipal } from '@/lib/agent-errors'

export default defineTool({
  description: 'Archive a project.',
  inputSchema: z.object({ projectId: z.string() }),
  execute: async ({ projectId }, ctx) => {
    const auth = ctx.session.auth.current

    const userError = requireUserPrincipal(auth, 'Only signed-in users can archive projects.')
    if (userError) return userError

    const permError = requirePermissions(auth, ['org:projects:archive'])
    if (permError) return permError

    // ... perform the archive in your data layer
    return { ok: true, projectId }
  },
})

Always return a structured error rather than throwing; the model relays the string to the user. A thrown error aborts the tool loop and reaches the user only as a generic failure.

Same shape for API key scopes:

const auth = ctx.session.auth.current
const scopes = auth?.attributes.scopes

if (auth?.principalType === 'machine' && auth.attributes.tokenType === 'api_key') {
  if (!Array.isArray(scopes) || !scopes.includes('projects:write')) {
    return {
      error: true,
      message: 'API key is missing the projects:write scope.',
    }
  }
}

OAuth via Clerk brokered connections

For tools that call a provider API, Clerk can broker the OAuth handshake and store the access token. When the token's missing, eve drives the consent flow through an AuthorizationDefinition on the tool. The three callbacks on defineInteractiveAuthorization:

  • getToken({ principal }) — return the stored token, or throw to request authorization.
  • startAuthorization({ callbackUrl }) — return a challenge URL to send the user through consent.
  • completeAuthorization({ principal }) — re-read the token after the redirect.

Wrap the three callbacks in a reusable clerkConnect(provider, options) factory. This guide uses GitHub as the example, but the same factory works for any Clerk-brokered OAuth provider. Swap the provider name and adjust the scopes.

Note

GitHub's repo scope isn't one of its defaults, so the connection must use custom credentials. Clerk's shared development credentials only grant a provider's default scopes. See the GitHub social connection guide to register your own OAuth app, and Configure additional OAuth scopes.

Start with a helper that reads Clerk's stored token for a given user and provider. It returns null if the token is missing or doesn't cover the required scopes, which sends the caller back through the connect flow to grant them.

lib/clerk-connect.ts
import { createClerkClient } from '@clerk/backend'

const clerk = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY })

export async function readProviderToken(
  userId: string,
  provider: string,
  requiredScopes: readonly string[],
): Promise<string | null> {
  const res = await clerk.users.getUserOauthAccessToken(userId, provider)
  const entry = res.data[0]
  if (!entry?.token) return null

  if (requiredScopes.length) {
    const granted = new Set(entry.scopes ?? [])
    if (!requiredScopes.every((s) => granted.has(s))) return null
  }

  return entry.token
}

getToken runs first on every tool call. Return the token if Clerk already has one for the caller. Throw ConnectionAuthorizationRequiredError to trigger the consent flow.

lib/clerk-connect.ts
import {
  type AuthorizationDefinition,
  ConnectionAuthorizationFailedError,
  ConnectionAuthorizationRequiredError,
  defineInteractiveAuthorization,
} from 'eve/connections'

type ClerkConnectOptions = {
  readonly scopes?: readonly string[]
}

export function clerkConnect(
  provider: string,
  options: ClerkConnectOptions = {},
): AuthorizationDefinition {
  const scopes = options.scopes ?? []

  return defineInteractiveAuthorization({
    displayName: provider,

    getToken: async ({ principal }) => {
      if (principal.type !== 'user') {
        throw new ConnectionAuthorizationRequiredError(provider, {
          message: `Connecting ${provider} requires a signed-in user.`,
        })
      }

      const token = await readProviderToken(principal.id, provider, scopes)
      if (!token) {
        throw new ConnectionAuthorizationRequiredError(provider, {
          message: `Connect your ${provider} account to continue.`,
        })
      }

      return { token }
    },

    // startAuthorization and completeAuthorization continue below
  })
}

startAuthorization builds the URL eve sends the user to. Forward callbackUrl so the dashboard page can redirect back when consent finishes, and pass any required scopes.

lib/clerk-connect.ts
// Inside defineInteractiveAuthorization({...})
startAuthorization: async ({ callbackUrl }) => {
  const params = new URLSearchParams({ return: callbackUrl })
  if (scopes.length) params.set('scopes', scopes.join(' '))
  params.set('prompt', 'select_account consent')

  return {
    challenge: {
      url: `/connect/${provider}?${params.toString()}`,
      displayName: provider,
    },
  }
},

After the redirect, completeAuthorization re-reads the stored token. If consent succeeded, the token is now there; if not, throw to fail the flow.

lib/clerk-connect.ts
// Inside defineInteractiveAuthorization({...})
completeAuthorization: async ({ principal }) => {
  if (principal.type !== 'user') {
    throw new ConnectionAuthorizationFailedError(provider, {
      message: `Connect flow finished without a signed-in user.`,
      reason: 'no_user',
      retryable: false,
    })
  }

  const token = await readProviderToken(principal.id, provider, scopes)
  if (!token) {
    throw new ConnectionAuthorizationFailedError(provider, {
      message: `${provider} did not return an access token after connecting.`,
      reason: 'no_token',
      retryable: false,
    })
  }

  return { token }
},

Now any tool can opt into Clerk-brokered OAuth with one line: auth: clerkConnect('github', { scopes: ['repo'] }). By the time tool execution reaches ctx.getToken(), eve has already driven the consent flow if it needed to.

agent/tools/list_repos.ts
import { defineTool } from 'eve/tools'
import { z } from 'zod'
import { clerkConnect } from '@/lib/clerk-connect'

export default defineTool({
  description: "List the caller's GitHub repositories.",
  inputSchema: z.object({}),
  // `repo` covers public + private repos; use `public_repo` for public only.
  auth: clerkConnect('github', { scopes: ['repo'] }),
  execute: async (_input, ctx) => {
    const { token } = await ctx.getToken()
    const res = await fetch('https://api.github.com/user/repos', {
      headers: { authorization: `Bearer ${token}` },
    })
    return await res.json()
  },
})

The dashboard /connect/[provider] route

The challenge URL points at a dashboard page that runs Clerk's frontend connect flow — createExternalAccount, or reauthorize when the provider's already connected but missing a scope — and redirects back to the return URL. A dynamic [provider] route lets one page handle every provider you pass to clerkConnect.

app/connect/[provider]/page.tsx
'use client'

import { useReverification, useUser } from '@clerk/nextjs'
import type { OAuthStrategy } from '@clerk/types'
import { useParams, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'

export default function ConnectProviderPage() {
  const { user, isLoaded } = useUser()
  const { provider } = useParams<{ provider: string }>()
  const params = useSearchParams()
  const [error, setError] = useState<string>()
  const started = useRef(false)

  // createExternalAccount can require step-up reverification.
  const createExternalAccount = useReverification(
    (args: Parameters<NonNullable<typeof user>['createExternalAccount']>[0]) =>
      user?.createExternalAccount(args),
  )

  useEffect(() => {
    if (!isLoaded || !user || started.current) return
    started.current = true

    const returnUrl = params.get('return') ?? '/'
    const additionalScopes = params.get('scopes')?.split(' ').filter(Boolean) ?? []
    const oidcPrompt = params.get('prompt') ?? undefined
    const strategy = `oauth_${provider}` as OAuthStrategy
    const existing = user.externalAccounts.find((a) => a.provider === provider)

    void (async () => {
      try {
        // If the provider's already connected but without the scope (e.g. the
        // user signed in with GitHub), reauthorize to add it — a fresh
        // createExternalAccount would fail or loop.
        const account =
          existing && additionalScopes.length
            ? await existing.reauthorize({ additionalScopes, redirectUrl: returnUrl, oidcPrompt })
            : await createExternalAccount({
                strategy,
                additionalScopes,
                redirectUrl: returnUrl,
                oidcPrompt,
              })

        const url = account?.verification?.externalVerificationRedirectURL
        if (url) {
          // Full-page navigation; router.push is unreliable for cross-origin URLs.
          window.location.href = url.href
          return
        }
        setError('Clerk did not return a verification URL.')
      } catch (err) {
        setError(err instanceof Error ? err.message : String(err))
      }
    })()
  }, [isLoaded, user, provider, params, createExternalAccount])

  return <p>{error ? `Connection failed: ${error}` : `Connecting ${provider}…`}</p>
}

Fallback for API key callers

API keys can't drive a browser consent flow, but the user behind the key may already have connected. Read their stored token directly inside execute using the exported readProviderToken.

agent/tools/list_repos.ts
import { readProviderToken } from '@/lib/clerk-connect'

execute: async (_input, ctx) => {
  const auth = ctx.session.auth.current
  let token: string | null = null

  if (auth?.principalType === 'user') {
    ;({ token } = await ctx.getToken())
  } else if (
    auth?.principalType === 'machine' &&
    auth.attributes.tokenType === 'api_key' &&
    typeof auth.attributes.userId === 'string'
  ) {
    token = await readProviderToken(auth.attributes.userId, 'github', ['repo'])
  }

  if (!token) {
    return { error: true, message: 'Connect your GitHub account to list repositories.' }
  }
  // ... call the provider API with `token`
},

Feedback

What did you think of this content?

Last updated on