Fix/percistent mcp connection (#392)
* feat(oauth): add support for refresh tokens in OAuth flow and update database schema * feat(prompts): add MCP prompts and corresponding functionality for prompt retrieval * feat(auth): enhance error handling for refresh token operations and validation
This commit is contained in:
@@ -13,7 +13,7 @@ export async function GET() {
|
|||||||
token_endpoint: `${appUrl}/api/mcp-oauth/token`,
|
token_endpoint: `${appUrl}/api/mcp-oauth/token`,
|
||||||
registration_endpoint: `${appUrl}/api/mcp-oauth/register`,
|
registration_endpoint: `${appUrl}/api/mcp-oauth/register`,
|
||||||
response_types_supported: ['code'],
|
response_types_supported: ['code'],
|
||||||
grant_types_supported: ['authorization_code'],
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
code_challenge_methods_supported: ['S256'],
|
code_challenge_methods_supported: ['S256'],
|
||||||
token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
|
token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
|
||||||
scopes_supported: ['mcp'],
|
scopes_supported: ['mcp'],
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
supabaseFactory: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createServiceClientNoCookies: () => mocks.supabaseFactory(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth/oauth-codes', () => ({
|
||||||
|
decryptAuthCode: vi.fn(),
|
||||||
|
verifyPkce: vi.fn(),
|
||||||
|
hashAuthCode: vi.fn(() => 'auth-code-hash'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { POST } from '../route'
|
||||||
|
import { decryptAuthCode, verifyPkce } from '@/lib/auth/oauth-codes'
|
||||||
|
import { generateRefreshToken } from '@/lib/auth/api-keys'
|
||||||
|
|
||||||
|
function formRequest(body: Record<string, string>) {
|
||||||
|
return new Request('http://localhost/api/mcp-oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(body).toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/mcp-oauth/token', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('grant_type validation', () => {
|
||||||
|
it('rejects unknown grant types', async () => {
|
||||||
|
const res = await POST(formRequest({ grant_type: 'password' }))
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('unsupported_grant_type')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unsupported content type', async () => {
|
||||||
|
const req = new Request('http://localhost/api/mcp-oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
body: 'grant_type=authorization_code',
|
||||||
|
})
|
||||||
|
const res = await POST(req)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('authorization_code grant', () => {
|
||||||
|
it('returns access_token, refresh_token, and expires_in on success', async () => {
|
||||||
|
vi.mocked(decryptAuthCode).mockReturnValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
codeChallenge: 'challenge',
|
||||||
|
redirectUri: 'https://claude.ai/api/cb',
|
||||||
|
exp: Date.now() + 60_000,
|
||||||
|
})
|
||||||
|
vi.mocked(verifyPkce).mockReturnValue(true)
|
||||||
|
|
||||||
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||||
|
mocks.supabaseFactory.mockReturnValue(supabase)
|
||||||
|
enqueueMany([
|
||||||
|
{ data: null, error: null }, // insert into oauth_used_codes
|
||||||
|
{ data: null, error: null }, // delete expired codes (best-effort)
|
||||||
|
{ data: null, error: null }, // insert into api_keys
|
||||||
|
])
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: 'ciphertext',
|
||||||
|
code_verifier: 'verifier',
|
||||||
|
redirect_uri: 'https://claude.ai/api/cb',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.access_token).toMatch(/^gnubok_sk_/)
|
||||||
|
expect(body.refresh_token).toMatch(/^gnubok_rt_/)
|
||||||
|
expect(body.token_type).toBe('Bearer')
|
||||||
|
expect(body.expires_in).toBe(3600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an already-used auth code (replay)', async () => {
|
||||||
|
vi.mocked(decryptAuthCode).mockReturnValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
codeChallenge: 'challenge',
|
||||||
|
redirectUri: 'https://claude.ai/api/cb',
|
||||||
|
exp: Date.now() + 60_000,
|
||||||
|
})
|
||||||
|
vi.mocked(verifyPkce).mockReturnValue(true)
|
||||||
|
|
||||||
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
|
mocks.supabaseFactory.mockReturnValue(supabase)
|
||||||
|
enqueue({ data: null, error: { message: 'unique violation' } })
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: 'ciphertext',
|
||||||
|
code_verifier: 'verifier',
|
||||||
|
redirect_uri: 'https://claude.ai/api/cb',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('invalid_grant')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when PKCE verification fails', async () => {
|
||||||
|
vi.mocked(decryptAuthCode).mockReturnValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
codeChallenge: 'challenge',
|
||||||
|
redirectUri: 'https://claude.ai/api/cb',
|
||||||
|
exp: Date.now() + 60_000,
|
||||||
|
})
|
||||||
|
vi.mocked(verifyPkce).mockReturnValue(false)
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: 'ciphertext',
|
||||||
|
code_verifier: 'wrong',
|
||||||
|
redirect_uri: 'https://claude.ai/api/cb',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('invalid_grant')
|
||||||
|
expect(body.error_description).toContain('PKCE')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('refresh_token grant', () => {
|
||||||
|
it('rotates both tokens and returns a fresh access_token', async () => {
|
||||||
|
const { token: refreshToken } = generateRefreshToken()
|
||||||
|
|
||||||
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||||
|
mocks.supabaseFactory.mockReturnValue(supabase)
|
||||||
|
enqueueMany([
|
||||||
|
{ data: { id: 'key-1', revoked_at: null }, error: null }, // SELECT
|
||||||
|
{ data: [{ id: 'key-1' }], error: null }, // UPDATE ... RETURNING
|
||||||
|
])
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.access_token).toMatch(/^gnubok_sk_/)
|
||||||
|
expect(body.refresh_token).toMatch(/^gnubok_rt_/)
|
||||||
|
expect(body.refresh_token).not.toBe(refreshToken) // rotated
|
||||||
|
expect(body.expires_in).toBe(3600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when refresh_token is missing', async () => {
|
||||||
|
const res = await POST(formRequest({ grant_type: 'refresh_token' }))
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('invalid_request')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when refresh_token is unknown', async () => {
|
||||||
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
|
mocks.supabaseFactory.mockReturnValue(supabase)
|
||||||
|
enqueue({ data: null, error: null }) // SELECT — no row
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: 'gnubok_rt_unknown',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('invalid_grant')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when the api_key is revoked', async () => {
|
||||||
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
|
mocks.supabaseFactory.mockReturnValue(supabase)
|
||||||
|
enqueue({
|
||||||
|
data: { id: 'key-1', revoked_at: '2026-05-01T00:00:00Z' },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: 'gnubok_rt_anything',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('invalid_grant')
|
||||||
|
expect(body.error_description).toContain('revoked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 500 when the lookup fails with a DB error', async () => {
|
||||||
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
|
mocks.supabaseFactory.mockReturnValue(supabase)
|
||||||
|
enqueue({ data: null, error: { message: 'connection reset' } })
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: 'gnubok_rt_anything',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(500)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('server_error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 500 when the rotation update fails with a DB error', async () => {
|
||||||
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||||
|
mocks.supabaseFactory.mockReturnValue(supabase)
|
||||||
|
enqueueMany([
|
||||||
|
{ data: { id: 'key-1', revoked_at: null }, error: null }, // SELECT
|
||||||
|
{ data: null, error: { message: 'deadlock detected' } }, // UPDATE — DB error
|
||||||
|
])
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: 'gnubok_rt_anything',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(500)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('server_error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when the CAS update affects 0 rows (concurrent reuse)', async () => {
|
||||||
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||||
|
mocks.supabaseFactory.mockReturnValue(supabase)
|
||||||
|
enqueueMany([
|
||||||
|
{ data: { id: 'key-1', revoked_at: null }, error: null }, // SELECT
|
||||||
|
{ data: [], error: null }, // UPDATE — 0 rows (lost the CAS race)
|
||||||
|
])
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
formRequest({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: 'gnubok_rt_anything',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.error).toBe('invalid_grant')
|
||||||
|
expect(body.error_description).toContain('already used')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
|
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
|
||||||
import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys'
|
import {
|
||||||
|
generateApiKey,
|
||||||
|
generateRefreshToken,
|
||||||
|
hashRefreshToken,
|
||||||
|
createServiceClientNoCookies,
|
||||||
|
ALL_SCOPES,
|
||||||
|
} from '@/lib/auth/api-keys'
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_TTL_SECONDS = 3600
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth 2.0 Token Endpoint.
|
* OAuth 2.0 Token Endpoint.
|
||||||
*
|
*
|
||||||
* Exchanges an authorization code for an API key (access token).
|
* Supports two grant types:
|
||||||
* 1. Decrypts the stateless auth code
|
* - authorization_code: exchange a PKCE-protected auth code for a fresh
|
||||||
* 2. Checks for replay (single-use enforcement per OAuth 2.1 §4.1.2)
|
* api_key (access_token) plus a refresh_token.
|
||||||
* 3. Verifies PKCE (S256 only)
|
* - refresh_token: rotate the refresh_token and return the same api_key
|
||||||
* 4. Creates the API key (deferred from /authorize to prevent orphaned keys)
|
* with a fresh expires_in. The api_key itself does not expire
|
||||||
* 5. Returns the key as a bearer token
|
* server-side; expires_in is a hint so clients refresh on a cadence.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
let params: URLSearchParams
|
let params: URLSearchParams
|
||||||
@@ -28,17 +36,29 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const grantType = params.get('grant_type')
|
const grantType = params.get('grant_type')
|
||||||
|
|
||||||
|
if (grantType === 'authorization_code') {
|
||||||
|
return handleAuthorizationCodeGrant(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grantType === 'refresh_token') {
|
||||||
|
return handleRefreshTokenGrant(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'unsupported_grant_type',
|
||||||
|
error_description: 'Only authorization_code and refresh_token are supported',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthorizationCodeGrant(params: URLSearchParams) {
|
||||||
const code = params.get('code')
|
const code = params.get('code')
|
||||||
const codeVerifier = params.get('code_verifier')
|
const codeVerifier = params.get('code_verifier')
|
||||||
const redirectUri = params.get('redirect_uri')
|
const redirectUri = params.get('redirect_uri')
|
||||||
|
|
||||||
if (grantType !== 'authorization_code') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'unsupported_grant_type', error_description: 'Only authorization_code is supported' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'invalid_request', error_description: 'Missing code parameter' },
|
{ error: 'invalid_request', error_description: 'Missing code parameter' },
|
||||||
@@ -46,7 +66,6 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the auth code
|
|
||||||
const payload = decryptAuthCode(code)
|
const payload = decryptAuthCode(code)
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -55,7 +74,6 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify redirect_uri matches
|
|
||||||
if (redirectUri && redirectUri !== payload.redirectUri) {
|
if (redirectUri && redirectUri !== payload.redirectUri) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'invalid_grant', error_description: 'redirect_uri mismatch' },
|
{ error: 'invalid_grant', error_description: 'redirect_uri mismatch' },
|
||||||
@@ -63,7 +81,6 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify PKCE (S256 only)
|
|
||||||
if (!codeVerifier) {
|
if (!codeVerifier) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'invalid_request', error_description: 'code_verifier is required' },
|
{ error: 'invalid_request', error_description: 'code_verifier is required' },
|
||||||
@@ -78,7 +95,6 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-use enforcement: check and mark code as used (atomically via unique constraint)
|
|
||||||
const codeHash = hashAuthCode(code)
|
const codeHash = hashAuthCode(code)
|
||||||
const supabase = createServiceClientNoCookies()
|
const supabase = createServiceClientNoCookies()
|
||||||
|
|
||||||
@@ -87,7 +103,6 @@ export async function POST(request: Request) {
|
|||||||
.insert({ code_hash: codeHash })
|
.insert({ code_hash: codeHash })
|
||||||
|
|
||||||
if (replayError) {
|
if (replayError) {
|
||||||
// Unique constraint violation = code already used
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'invalid_grant', error_description: 'Authorization code already used' },
|
{ error: 'invalid_grant', error_description: 'Authorization code already used' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -101,11 +116,10 @@ export async function POST(request: Request) {
|
|||||||
.lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
|
.lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
|
||||||
.then(() => {})
|
.then(() => {})
|
||||||
|
|
||||||
// Resolve company context for the user
|
|
||||||
const companyId = await requireCompanyId(supabase, payload.userId)
|
const companyId = await requireCompanyId(supabase, payload.userId)
|
||||||
|
|
||||||
// Create the API key now (after PKCE verification — prevents orphaned keys)
|
|
||||||
const { key, hash, prefix } = generateApiKey()
|
const { key, hash, prefix } = generateApiKey()
|
||||||
|
const refresh = generateRefreshToken()
|
||||||
|
|
||||||
const { error: insertError } = await supabase
|
const { error: insertError } = await supabase
|
||||||
.from('api_keys')
|
.from('api_keys')
|
||||||
@@ -116,6 +130,7 @@ export async function POST(request: Request) {
|
|||||||
key_prefix: prefix,
|
key_prefix: prefix,
|
||||||
name: 'MCP-klient (OAuth)',
|
name: 'MCP-klient (OAuth)',
|
||||||
scopes: ALL_SCOPES,
|
scopes: ALL_SCOPES,
|
||||||
|
refresh_token_hash: refresh.hash,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
@@ -128,6 +143,91 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
access_token: key,
|
access_token: key,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
|
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
||||||
|
refresh_token: refresh.token,
|
||||||
|
scope: 'mcp',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshTokenGrant(params: URLSearchParams) {
|
||||||
|
const refreshToken = params.get('refresh_token')
|
||||||
|
if (!refreshToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'invalid_request', error_description: 'refresh_token is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createServiceClientNoCookies()
|
||||||
|
const presentedHash = hashRefreshToken(refreshToken)
|
||||||
|
|
||||||
|
// Look up the api_key row by refresh_token_hash. The hash is unique among
|
||||||
|
// non-null values, so there's at most one match.
|
||||||
|
const { data: row, error: lookupError } = await supabase
|
||||||
|
.from('api_keys')
|
||||||
|
.select('id, revoked_at')
|
||||||
|
.eq('refresh_token_hash', presentedHash)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (lookupError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'server_error', error_description: 'Failed to look up refresh token' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'invalid_grant', error_description: 'Invalid refresh token' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.revoked_at) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'invalid_grant', error_description: 'Refresh token revoked' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate both tokens atomically. OAuth 2.1 §6.1 recommends rotating the
|
||||||
|
// refresh token; we also rotate the api_key because key_hash is one-way
|
||||||
|
// and we cannot recover the original plaintext to return to the client.
|
||||||
|
// The .eq('refresh_token_hash', presentedHash) guard makes this a CAS:
|
||||||
|
// a concurrent refresh with the same token will affect 0 rows.
|
||||||
|
const rotated = generateRefreshToken()
|
||||||
|
const { key: newKey, hash: newKeyHash, prefix: newKeyPrefix } = generateApiKey()
|
||||||
|
|
||||||
|
const { data: updated, error: updateError } = await supabase
|
||||||
|
.from('api_keys')
|
||||||
|
.update({
|
||||||
|
refresh_token_hash: rotated.hash,
|
||||||
|
key_hash: newKeyHash,
|
||||||
|
key_prefix: newKeyPrefix,
|
||||||
|
})
|
||||||
|
.eq('id', row.id)
|
||||||
|
.eq('refresh_token_hash', presentedHash)
|
||||||
|
.select('id')
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'server_error', error_description: 'Failed to rotate refresh token' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated || updated.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'invalid_grant', error_description: 'Refresh token already used' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: newKey,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
||||||
|
refresh_token: rotated.token,
|
||||||
scope: 'mcp',
|
scope: 'mcp',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { prompts, findPrompt } from '../prompts'
|
||||||
|
|
||||||
|
describe('mcp prompt registry', () => {
|
||||||
|
it('exposes the five single-action prompts', () => {
|
||||||
|
expect(prompts).toHaveLength(5)
|
||||||
|
const names = prompts.map((p) => p.name).sort()
|
||||||
|
expect(names).toEqual([
|
||||||
|
'cash_today',
|
||||||
|
'last_month_result',
|
||||||
|
'uncategorized_count',
|
||||||
|
'vat_due',
|
||||||
|
'whats_overdue',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every prompt has description and non-trivial text', () => {
|
||||||
|
for (const p of prompts) {
|
||||||
|
expect(p.description).toBeTruthy()
|
||||||
|
expect(p.text.length).toBeGreaterThan(40)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prompt names are snake_case and unique', () => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const p of prompts) {
|
||||||
|
expect(p.name).toMatch(/^[a-z][a-z0-9_]*$/)
|
||||||
|
expect(seen.has(p.name)).toBe(false)
|
||||||
|
seen.add(p.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findPrompt returns the matching prompt', () => {
|
||||||
|
expect(findPrompt('vat_due')?.name).toBe('vat_due')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findPrompt returns null for an unknown name', () => {
|
||||||
|
expect(findPrompt('does_not_exist')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { McpPrompt } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-action prompts. Each one is a Swedish slash-shortcut that directs
|
||||||
|
* the model to call exactly one gnubok tool and report a short answer.
|
||||||
|
*/
|
||||||
|
export const prompts: McpPrompt[] = [
|
||||||
|
{
|
||||||
|
name: 'whats_overdue',
|
||||||
|
description: 'Visa förfallna kundfakturor',
|
||||||
|
text:
|
||||||
|
'Lista mina förfallna kundfakturor. Anropa gnubok_list_invoices med status="overdue" ' +
|
||||||
|
'och svara på svenska med en kort lista: kundnamn, belopp, antal dagar förfallen. ' +
|
||||||
|
'Inga rekommendationer — bara fakta.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cash_today',
|
||||||
|
description: 'Visa banksaldo just nu',
|
||||||
|
text:
|
||||||
|
'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_get_balance_sheet ' +
|
||||||
|
'för dagens datum och rapportera saldot på konto 1930. Visa även de senaste 5 transaktionerna ' +
|
||||||
|
'via gnubok_list_uncategorized_transactions (limit=5, sortera nyast först — men inkludera även ' +
|
||||||
|
'kategoriserade om verktyget tillåter). Svara kort på svenska.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'last_month_result',
|
||||||
|
description: 'Resultat förra månaden',
|
||||||
|
text:
|
||||||
|
'Visa resultaträkningen för föregående kalendermånad. Anropa gnubok_get_income_statement ' +
|
||||||
|
'med rätt datumintervall och svara på svenska med tre siffror: intäkter, kostnader, resultat. ' +
|
||||||
|
'Ingen analys.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vat_due',
|
||||||
|
description: 'Moms att betala / återfå',
|
||||||
|
text:
|
||||||
|
'Vad är min momsskuld eller momsfordran för innevarande momsperiod? Anropa gnubok_get_vat_report ' +
|
||||||
|
'och rapportera enbart ruta 49 (att betala / att få tillbaka) samt deadline för deklarationen. ' +
|
||||||
|
'Ingen analys.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'uncategorized_count',
|
||||||
|
description: 'Okontrerade transaktioner',
|
||||||
|
text:
|
||||||
|
'Hur många banktransaktioner är okontrerade? Anropa gnubok_list_uncategorized_transactions ' +
|
||||||
|
'och svara på svenska med tre uppgifter: antal, datum för äldsta transaktion, totalbelopp. ' +
|
||||||
|
'Inga åtgärdsförslag.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function findPrompt(name: string): McpPrompt | null {
|
||||||
|
return prompts.find((p) => p.name === name) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { McpPrompt }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* MCP prompt — a server-defined chat template the user picks from a slash menu
|
||||||
|
* in their MCP client. Selecting a prompt sends the message text to the model,
|
||||||
|
* which then calls the relevant gnubok tools to satisfy the request.
|
||||||
|
*
|
||||||
|
* These prompts are intentionally argument-less and single-action: each one
|
||||||
|
* maps to one read tool, returning one short answer in Swedish.
|
||||||
|
*/
|
||||||
|
export interface McpPrompt {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
/** The user-role message text the client sends to the model. */
|
||||||
|
text: string
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import { generateARLedger } from '@/lib/reports/ar-ledger'
|
|||||||
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||||
import { RECEIPT_MATCHER_HTML } from './widget-html'
|
import { RECEIPT_MATCHER_HTML } from './widget-html'
|
||||||
import { dataResources, findResource, parseResourceQuery } from './resources'
|
import { dataResources, findResource, parseResourceQuery } from './resources'
|
||||||
|
import { prompts, findPrompt } from './prompts'
|
||||||
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
||||||
import { shouldAutoCommit } from '@/lib/pending-operations/should-auto-commit'
|
import { shouldAutoCommit } from '@/lib/pending-operations/should-auto-commit'
|
||||||
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
||||||
@@ -3560,6 +3561,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
tools: { listChanged: false },
|
tools: { listChanged: false },
|
||||||
resources: { listChanged: false },
|
resources: { listChanged: false },
|
||||||
|
prompts: { listChanged: false },
|
||||||
},
|
},
|
||||||
serverInfo: SERVER_INFO,
|
serverInfo: SERVER_INFO,
|
||||||
instructions: 'gnubok — Swedish bookkeeping via conversation. Categorize transactions, manage invoices (create, send, mark paid), view suppliers, match payments, get reports (trial balance, income statement, balance sheet, VAT, KPI, general ledger, AR/AP ledgers), and explore chart of accounts.',
|
instructions: 'gnubok — Swedish bookkeeping via conversation. Categorize transactions, manage invoices (create, send, mark paid), view suppliers, match payments, get reports (trial balance, income statement, balance sheet, VAT, KPI, general ledger, AR/AP ledgers), and explore chart of accounts.',
|
||||||
@@ -3712,6 +3714,37 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'prompts/list':
|
||||||
|
return NextResponse.json(
|
||||||
|
jsonRpc(id ?? null, {
|
||||||
|
prompts: prompts.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'prompts/get': {
|
||||||
|
const promptName = (params as Record<string, unknown>)?.name as string
|
||||||
|
const prompt = findPrompt(promptName)
|
||||||
|
if (!prompt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
jsonRpcError(id ?? null, -32602, `Unknown prompt: "${promptName}"`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
jsonRpc(id ?? null, {
|
||||||
|
description: prompt.description,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: { type: 'text', text: prompt.text },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
jsonRpcError(id ?? null, -32601, `Method not found: "${method}"`)
|
jsonRpcError(id ?? null, -32601, `Method not found: "${method}"`)
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ describe('validateApiKey', () => {
|
|||||||
expect(result).toEqual({ error: 'Invalid API key format', status: 401 })
|
expect(result).toEqual({ error: 'Invalid API key format', status: 401 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects a refresh token presented as Bearer with a specific message', async () => {
|
||||||
|
const result = await validateApiKey('gnubok_rt_some_refresh_token')
|
||||||
|
expect('status' in result && result.status).toBe(401)
|
||||||
|
expect('error' in result && result.error).toContain('Refresh token')
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects when RPC returns error', async () => {
|
it('rejects when RPC returns error', async () => {
|
||||||
setupMockRpc({ data: null, error: { message: 'db error' } })
|
setupMockRpc({ data: null, error: { message: 'db error' } })
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import crypto from 'crypto'
|
|||||||
import { createClient } from '@supabase/supabase-js'
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
const KEY_PREFIX = 'gnubok_sk_'
|
const KEY_PREFIX = 'gnubok_sk_'
|
||||||
|
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
|
||||||
|
|
||||||
// ── API Key Scopes ──────────────────────────────────────────
|
// ── API Key Scopes ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -140,6 +141,21 @@ export function hashApiKey(key: string): string {
|
|||||||
return crypto.createHash('sha256').update(key).digest('hex')
|
return crypto.createHash('sha256').update(key).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateRefreshToken(): { token: string; hash: string } {
|
||||||
|
const random = crypto.randomBytes(32).toString('base64url')
|
||||||
|
const token = `${REFRESH_TOKEN_PREFIX}${random}`
|
||||||
|
const hash = crypto.createHash('sha256').update(token).digest('hex')
|
||||||
|
return { token, hash }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashRefreshToken(token: string): string {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRefreshToken(token: string): boolean {
|
||||||
|
return token.startsWith(REFRESH_TOKEN_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
export function extractBearerToken(request: Request): string | null {
|
export function extractBearerToken(request: Request): string | null {
|
||||||
const authHeader = request.headers.get('authorization')
|
const authHeader = request.headers.get('authorization')
|
||||||
if (!authHeader?.startsWith('Bearer ')) return null
|
if (!authHeader?.startsWith('Bearer ')) return null
|
||||||
@@ -170,6 +186,13 @@ export async function validateApiKey(
|
|||||||
}
|
}
|
||||||
| { error: string; status: number }
|
| { error: string; status: number }
|
||||||
> {
|
> {
|
||||||
|
if (isRefreshToken(key)) {
|
||||||
|
return {
|
||||||
|
error: 'Refresh token cannot be used as access token; exchange it at /api/mcp-oauth/token',
|
||||||
|
status: 401,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!key.startsWith(KEY_PREFIX)) {
|
if (!key.startsWith(KEY_PREFIX)) {
|
||||||
return { error: 'Invalid API key format', status: 401 }
|
return { error: 'Invalid API key format', status: 401 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add refresh_token_hash to api_keys for OAuth refresh-token grant support.
|
||||||
|
-- OAuth-issued keys store a SHA-256 hashed refresh token here. Direct API
|
||||||
|
-- keys (created via the settings UI) leave this column NULL.
|
||||||
|
ALTER TABLE public.api_keys
|
||||||
|
ADD COLUMN refresh_token_hash text;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_api_keys_refresh_token_hash
|
||||||
|
ON public.api_keys (refresh_token_hash)
|
||||||
|
WHERE refresh_token_hash IS NOT NULL;
|
||||||
|
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
Reference in New Issue
Block a user