From c03582b5c7ebdfd3419427ddb7832a02b41a2aa9 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 5 May 2026 13:48:53 +0200 Subject: [PATCH] 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 --- .../oauth-authorization-server/route.ts | 2 +- .../mcp-oauth/token/__tests__/route.test.ts | 270 ++++++++++++++++++ app/api/mcp-oauth/token/route.ts | 142 +++++++-- .../mcp-server/__tests__/prompts.test.ts | 40 +++ .../general/mcp-server/prompts/index.ts | 55 ++++ .../general/mcp-server/prompts/types.ts | 14 + extensions/general/mcp-server/server.ts | 33 +++ lib/auth/__tests__/api-keys.test.ts | 6 + lib/auth/api-keys.ts | 23 ++ .../20260505120000_api_keys_refresh_token.sql | 11 + 10 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 app/api/mcp-oauth/token/__tests__/route.test.ts create mode 100644 extensions/general/mcp-server/__tests__/prompts.test.ts create mode 100644 extensions/general/mcp-server/prompts/index.ts create mode 100644 extensions/general/mcp-server/prompts/types.ts create mode 100644 supabase/migrations/20260505120000_api_keys_refresh_token.sql diff --git a/app/.well-known/oauth-authorization-server/route.ts b/app/.well-known/oauth-authorization-server/route.ts index f1397d79..1422725a 100644 --- a/app/.well-known/oauth-authorization-server/route.ts +++ b/app/.well-known/oauth-authorization-server/route.ts @@ -13,7 +13,7 @@ export async function GET() { token_endpoint: `${appUrl}/api/mcp-oauth/token`, registration_endpoint: `${appUrl}/api/mcp-oauth/register`, response_types_supported: ['code'], - grant_types_supported: ['authorization_code'], + grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none', 'client_secret_post'], scopes_supported: ['mcp'], diff --git a/app/api/mcp-oauth/token/__tests__/route.test.ts b/app/api/mcp-oauth/token/__tests__/route.test.ts new file mode 100644 index 00000000..b3263acd --- /dev/null +++ b/app/api/mcp-oauth/token/__tests__/route.test.ts @@ -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() + 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) { + 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') + }) + }) +}) diff --git a/app/api/mcp-oauth/token/route.ts b/app/api/mcp-oauth/token/route.ts index abf35387..d6234f79 100644 --- a/app/api/mcp-oauth/token/route.ts +++ b/app/api/mcp-oauth/token/route.ts @@ -1,17 +1,25 @@ import { NextResponse } from 'next/server' 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' +const ACCESS_TOKEN_TTL_SECONDS = 3600 + /** * OAuth 2.0 Token Endpoint. * - * Exchanges an authorization code for an API key (access token). - * 1. Decrypts the stateless auth code - * 2. Checks for replay (single-use enforcement per OAuth 2.1 §4.1.2) - * 3. Verifies PKCE (S256 only) - * 4. Creates the API key (deferred from /authorize to prevent orphaned keys) - * 5. Returns the key as a bearer token + * Supports two grant types: + * - authorization_code: exchange a PKCE-protected auth code for a fresh + * api_key (access_token) plus a refresh_token. + * - refresh_token: rotate the refresh_token and return the same api_key + * with a fresh expires_in. The api_key itself does not expire + * server-side; expires_in is a hint so clients refresh on a cadence. */ export async function POST(request: Request) { let params: URLSearchParams @@ -28,17 +36,29 @@ export async function POST(request: Request) { } 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 codeVerifier = params.get('code_verifier') 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) { return NextResponse.json( { 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) if (!payload) { return NextResponse.json( @@ -55,7 +74,6 @@ export async function POST(request: Request) { ) } - // Verify redirect_uri matches if (redirectUri && redirectUri !== payload.redirectUri) { return NextResponse.json( { error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, @@ -63,7 +81,6 @@ export async function POST(request: Request) { ) } - // Verify PKCE (S256 only) if (!codeVerifier) { return NextResponse.json( { 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 supabase = createServiceClientNoCookies() @@ -87,7 +103,6 @@ export async function POST(request: Request) { .insert({ code_hash: codeHash }) if (replayError) { - // Unique constraint violation = code already used return NextResponse.json( { error: 'invalid_grant', error_description: 'Authorization code already used' }, { status: 400 } @@ -101,11 +116,10 @@ export async function POST(request: Request) { .lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString()) .then(() => {}) - // Resolve company context for the user const companyId = await requireCompanyId(supabase, payload.userId) - // Create the API key now (after PKCE verification — prevents orphaned keys) const { key, hash, prefix } = generateApiKey() + const refresh = generateRefreshToken() const { error: insertError } = await supabase .from('api_keys') @@ -116,6 +130,7 @@ export async function POST(request: Request) { key_prefix: prefix, name: 'MCP-klient (OAuth)', scopes: ALL_SCOPES, + refresh_token_hash: refresh.hash, }) if (insertError) { @@ -128,6 +143,91 @@ export async function POST(request: Request) { return NextResponse.json({ access_token: key, 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', }) } diff --git a/extensions/general/mcp-server/__tests__/prompts.test.ts b/extensions/general/mcp-server/__tests__/prompts.test.ts new file mode 100644 index 00000000..2a96620d --- /dev/null +++ b/extensions/general/mcp-server/__tests__/prompts.test.ts @@ -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() + 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() + }) +}) diff --git a/extensions/general/mcp-server/prompts/index.ts b/extensions/general/mcp-server/prompts/index.ts new file mode 100644 index 00000000..1bbce9c4 --- /dev/null +++ b/extensions/general/mcp-server/prompts/index.ts @@ -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 } diff --git a/extensions/general/mcp-server/prompts/types.ts b/extensions/general/mcp-server/prompts/types.ts new file mode 100644 index 00000000..f50e0f7a --- /dev/null +++ b/extensions/general/mcp-server/prompts/types.ts @@ -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 +} diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index de4362f4..e6ced171 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -26,6 +26,7 @@ import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' import { RECEIPT_MATCHER_HTML } from './widget-html' import { dataResources, findResource, parseResourceQuery } from './resources' +import { prompts, findPrompt } from './prompts' import { getRiskLevel } from '@/lib/pending-operations/risk-tiers' import { shouldAutoCommit } from '@/lib/pending-operations/should-auto-commit' import { commitPendingOperation } from '@/lib/pending-operations/commit' @@ -3560,6 +3561,7 @@ export async function handleMcpRequest(request: Request): Promise { capabilities: { tools: { listChanged: false }, resources: { listChanged: false }, + prompts: { listChanged: false }, }, 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.', @@ -3712,6 +3714,37 @@ export async function handleMcpRequest(request: Request): Promise { ) } + 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)?.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: return NextResponse.json( jsonRpcError(id ?? null, -32601, `Method not found: "${method}"`) diff --git a/lib/auth/__tests__/api-keys.test.ts b/lib/auth/__tests__/api-keys.test.ts index 2e294780..2a21a1c3 100644 --- a/lib/auth/__tests__/api-keys.test.ts +++ b/lib/auth/__tests__/api-keys.test.ts @@ -173,6 +173,12 @@ describe('validateApiKey', () => { 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 () => { setupMockRpc({ data: null, error: { message: 'db error' } }) diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index f3836d1f..f897f2af 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -2,6 +2,7 @@ import crypto from 'crypto' import { createClient } from '@supabase/supabase-js' const KEY_PREFIX = 'gnubok_sk_' +const REFRESH_TOKEN_PREFIX = 'gnubok_rt_' // ── API Key Scopes ────────────────────────────────────────── @@ -140,6 +141,21 @@ export function hashApiKey(key: string): string { 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 { const authHeader = request.headers.get('authorization') if (!authHeader?.startsWith('Bearer ')) return null @@ -170,6 +186,13 @@ export async function validateApiKey( } | { 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)) { return { error: 'Invalid API key format', status: 401 } } diff --git a/supabase/migrations/20260505120000_api_keys_refresh_token.sql b/supabase/migrations/20260505120000_api_keys_refresh_token.sql new file mode 100644 index 00000000..a243edd8 --- /dev/null +++ b/supabase/migrations/20260505120000_api_keys_refresh_token.sql @@ -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';