diff --git a/src/lib/auth-api.ts b/src/lib/auth-api.ts index 19a2c3a..c65bc43 100644 --- a/src/lib/auth-api.ts +++ b/src/lib/auth-api.ts @@ -1,5 +1,15 @@ import { API_KEY_PREFIX, verifyApiKey } from './api-keys'; import { createAuth, type AuthEnv } from './auth'; +import { + findReaderUserByGoogleId, + type ReaderAuth0Env, + verifyReaderAuth0Subject, +} from './auth0-mcp'; + +export type McpAuthResult = + | { status: 'authorized'; userId: string } + | { status: 'account_not_found' } + | { status: 'invalid' }; /** Resolve only a dedicated long-lived Reader API key; never use browser auth. */ export async function getApiKeyUserId(headers: Headers): Promise { @@ -16,6 +26,30 @@ export async function getApiKeyUserId(headers: Headers): Promise return verifyApiKey(value); } +/** Resolve a Reader PAT or a short-lived, user-specific Auth0 MCP token. */ +export async function authenticateMcpReader( + headers: Headers, + env: ReaderAuth0Env +): Promise { + const authHeader = headers.get('authorization') ?? headers.get('Authorization'); + if (!authHeader) return { status: 'invalid' }; + const [scheme, value, extra] = authHeader.trim().split(/\s+/, 3); + if (extra !== undefined || scheme?.toLowerCase() !== 'bearer' || !value) { + return { status: 'invalid' }; + } + if (value.startsWith(API_KEY_PREFIX)) { + const userId = await verifyApiKey(value); + return userId ? { status: 'authorized', userId } : { status: 'invalid' }; + } + if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(value)) { + return { status: 'invalid' }; + } + const googleId = await verifyReaderAuth0Subject(value, env); + if (!googleId) return { status: 'invalid' }; + const userId = await findReaderUserByGoogleId(googleId); + return userId ? { status: 'authorized', userId } : { status: 'account_not_found' }; +} + /** * Resolve the authenticated user for an API request. * diff --git a/src/lib/auth0-mcp.test.ts b/src/lib/auth0-mcp.test.ts new file mode 100644 index 0000000..cd0a22f --- /dev/null +++ b/src/lib/auth0-mcp.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { verifyReaderAuth0Subject } from './auth0-mcp'; + +const issuer = 'https://fleet-test.us.auth0.com/'; +const audience = 'https://mcp.significanthobbies.com/reader/mcp'; + +function base64url(value: Uint8Array | string): string { + const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value; + return btoa(String.fromCharCode(...bytes)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', ''); +} + +async function fixture() { + const pair = (await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + )) as CryptoKeyPair; + const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey); + const keys = [{ ...publicJwk, alg: 'RS256', kid: 'reader-test' }]; + return { + keys, + async token(overrides: Record = {}) { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: 'RS256', kid: 'reader-test', typ: 'JWT' })); + const payload = base64url( + JSON.stringify({ + iss: issuer, + aud: audience, + sub: 'google-oauth2|google-user-1', + iat: now, + exp: now + 300, + permissions: ['reader.read'], + ...overrides, + }) + ); + const input = `${header}.${payload}`; + const signature = await crypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + pair.privateKey, + new TextEncoder().encode(input) + ); + return `${input}.${base64url(new Uint8Array(signature))}`; + }, + }; +} + +describe('Reader Auth0 MCP verification', () => { + it('accepts only the exact Google subject, audience, scope, and bounded lifetime', async () => { + const signed = await fixture(); + const env = { AUTH0_ISSUER: issuer, AUTH0_MCP_AUDIENCE: audience }; + await expect(verifyReaderAuth0Subject(await signed.token(), env, signed.keys)).resolves.toBe( + 'google-user-1' + ); + + for (const overrides of [ + { aud: 'https://mcp.significanthobbies.com/calorie/mcp' }, + { permissions: ['calorie.read'] }, + { sub: 'auth0|not-google' }, + { exp: Math.floor(Date.now() / 1000) + 7_200 }, + ]) { + await expect( + verifyReaderAuth0Subject(await signed.token(overrides), env, signed.keys) + ).resolves.toBeNull(); + } + }); +}); diff --git a/src/lib/auth0-mcp.ts b/src/lib/auth0-mcp.ts new file mode 100644 index 0000000..e76651e --- /dev/null +++ b/src/lib/auth0-mcp.ts @@ -0,0 +1,118 @@ +import { and, eq } from 'drizzle-orm'; +import { verifyWithJwks } from 'hono/jwt'; + +import { db } from './db/client'; +import { accounts, baAccounts } from './db/schema'; + +const REQUIRED_SCOPE = 'reader.read'; +const MAX_TOKEN_LIFETIME_SECONDS = 3_600; +const GOOGLE_SUBJECT = /^google-oauth2\|([A-Za-z0-9._-]{3,256})$/u; + +type JwksOptions = Parameters[1]; +export type ReaderAuth0Env = { + AUTH0_ISSUER?: string; + AUTH0_MCP_AUDIENCE?: string; +}; + +function auth0Issuer(value: string | undefined): string | null { + try { + const url = new URL(value ?? ''); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.pathname !== '/' || + url.search || + url.hash || + !url.hostname.endsWith('.auth0.com') + ) { + return null; + } + return url.href; + } catch { + return null; + } +} + +function exactAudience(value: string | undefined): string | null { + try { + const url = new URL(value ?? ''); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.search || + url.hash || + url.pathname !== '/reader/mcp' + ) { + return null; + } + return url.href; + } catch { + return null; + } +} + +function stringClaims(value: unknown): string[] { + if (typeof value === 'string') return value.split(/\s+/u).filter(Boolean); + if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value; + return []; +} + +export async function verifyReaderAuth0Subject( + token: string, + env: ReaderAuth0Env, + keys?: JwksOptions['keys'] +): Promise { + const issuer = auth0Issuer(env.AUTH0_ISSUER); + const audience = exactAudience(env.AUTH0_MCP_AUDIENCE); + if (!issuer || !audience) return null; + try { + const payload = await verifyWithJwks( + token, + { + ...(keys ? { keys } : { jwks_uri: new URL('.well-known/jwks.json', issuer).href }), + allowedAlgorithms: ['RS256'], + verification: { iss: issuer, aud: audience }, + }, + { cf: { cacheEverything: true, cacheTtl: 3_600 } } as RequestInit + ); + const match = typeof payload.sub === 'string' ? GOOGLE_SUBJECT.exec(payload.sub) : null; + const permissions = new Set([ + ...stringClaims(payload.scope), + ...stringClaims(payload.scopes), + ...stringClaims(payload.permissions), + ]); + if ( + !match || + typeof payload.iat !== 'number' || + typeof payload.exp !== 'number' || + payload.exp <= payload.iat || + payload.exp - payload.iat > MAX_TOKEN_LIFETIME_SECONDS || + !permissions.has(REQUIRED_SCOPE) + ) { + return null; + } + return match[1] ?? null; + } catch { + return null; + } +} + +export async function findReaderUserByGoogleId(googleId: string): Promise { + const [betterAuthAccount] = await db + .select({ userId: baAccounts.userId }) + .from(baAccounts) + .where(and(eq(baAccounts.providerId, 'google'), eq(baAccounts.accountId, googleId))) + .limit(1); + if (betterAuthAccount?.userId) return betterAuthAccount.userId; + + const [legacyAccount] = await db + .select({ userId: accounts.userId }) + .from(accounts) + .where(and(eq(accounts.provider, 'google'), eq(accounts.providerAccountId, googleId))) + .limit(1); + return legacyAccount?.userId ?? null; +} diff --git a/src/lib/worker-env.ts b/src/lib/worker-env.ts index 789179b..af0812c 100644 --- a/src/lib/worker-env.ts +++ b/src/lib/worker-env.ts @@ -6,6 +6,8 @@ export type WorkerEnv = { BETTER_AUTH_BASE_URL?: string; GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_SECRET?: string; + AUTH0_ISSUER?: string; + AUTH0_MCP_AUDIENCE?: string; AI_GATEWAY_API_KEY?: string; AI_API_KEY?: string; AI_BASE_URL?: string; diff --git a/src/worker/routes/__tests__/mcp.test.ts b/src/worker/routes/__tests__/mcp.test.ts index ac1528d..453a7dc 100644 --- a/src/worker/routes/__tests__/mcp.test.ts +++ b/src/worker/routes/__tests__/mcp.test.ts @@ -2,14 +2,14 @@ import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ - getApiKeyUserId: vi.fn(), + authenticateMcpReader: vi.fn(), searchArticleSummaries: vi.fn(), fetchArticleById: vi.fn(), fetchLists: vi.fn(), })); vi.mock('../../../lib/auth-api', () => ({ - getApiKeyUserId: mocks.getApiKeyUserId, + authenticateMcpReader: mocks.authenticateMcpReader, })); vi.mock('../../../lib/articles-db', () => ({ searchArticleSummaries: mocks.searchArticleSummaries, @@ -25,25 +25,33 @@ app.route('/api/mcp', mcpRoutes); describe('Reader MCP read projections', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.getApiKeyUserId.mockResolvedValue('owner-1'); + mocks.authenticateMcpReader.mockResolvedValue({ status: 'authorized', userId: 'owner-1' }); mocks.searchArticleSummaries.mockResolvedValue({ items: [], total: 0, nextOffset: null }); mocks.fetchLists.mockResolvedValue([]); }); it('fails closed without an owner credential', async () => { - mocks.getApiKeyUserId.mockResolvedValue(null); + mocks.authenticateMcpReader.mockResolvedValue({ status: 'invalid' }); const response = await app.request('/api/mcp/reading?q=test'); expect(response.status).toBe(401); expect(mocks.searchArticleSummaries).not.toHaveBeenCalled(); }); it('does not treat a browser session cookie as an MCP credential', async () => { - mocks.getApiKeyUserId.mockResolvedValue(null); + mocks.authenticateMcpReader.mockResolvedValue({ status: 'invalid' }); const response = await app.request('/api/mcp/reading?q=test', { headers: { Cookie: 'better-auth.session_token=browser-session' }, }); expect(response.status).toBe(401); - expect(mocks.getApiKeyUserId).toHaveBeenCalledOnce(); + expect(mocks.authenticateMcpReader).toHaveBeenCalledOnce(); + expect(mocks.searchArticleSummaries).not.toHaveBeenCalled(); + }); + + it('explains when the Google account has not used Reader yet', async () => { + mocks.authenticateMcpReader.mockResolvedValue({ status: 'account_not_found' }); + const response = await app.request('/api/mcp/reading?q=test'); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: 'ACCOUNT_NOT_FOUND' }); expect(mocks.searchArticleSummaries).not.toHaveBeenCalled(); }); diff --git a/src/worker/routes/mcp.ts b/src/worker/routes/mcp.ts index b88fd04..0df995a 100644 --- a/src/worker/routes/mcp.ts +++ b/src/worker/routes/mcp.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { fetchArticleById, searchArticleSummaries } from '../../lib/articles-db'; -import { getApiKeyUserId } from '../../lib/auth-api'; +import { authenticateMcpReader } from '../../lib/auth-api'; import { fetchLists } from '../../lib/lists-db'; import type { WorkerEnv } from '../../lib/worker-env'; @@ -12,13 +12,32 @@ function pageValue(value: string | undefined, fallback: number, max: number) { return Number.isInteger(parsed) ? Math.min(Math.max(parsed, 0), max) : fallback; } -async function ownerId(headers: Headers) { - return getApiKeyUserId(headers); +async function ownerId(headers: Headers, env: WorkerEnv) { + return authenticateMcpReader(headers, env); +} + +function authError(result: Awaited>) { + return result.status === 'account_not_found' + ? { + status: 403 as const, + body: { + code: 'ACCOUNT_NOT_FOUND', + message: 'Sign in to Reader with the same Google account first.', + }, + } + : { + status: 401 as const, + body: { code: 'UNAUTHORIZED', message: 'Read credential required.' }, + }; } mcpReads.get('/reading', async (c) => { - const userId = await ownerId(c.req.raw.headers); - if (!userId) return c.json({ code: 'UNAUTHORIZED', message: 'Read credential required.' }, 401); + const auth = await ownerId(c.req.raw.headers, c.env); + if (auth.status !== 'authorized') { + const error = authError(auth); + return c.json(error.body, error.status); + } + const userId = auth.userId; const type = c.req.query('type'); const result = await searchArticleSummaries(userId, { query: c.req.query('q'), @@ -32,8 +51,12 @@ mcpReads.get('/reading', async (c) => { }); mcpReads.get('/reading/:id', async (c) => { - const userId = await ownerId(c.req.raw.headers); - if (!userId) return c.json({ code: 'UNAUTHORIZED', message: 'Read credential required.' }, 401); + const auth = await ownerId(c.req.raw.headers, c.env); + if (auth.status !== 'authorized') { + const error = authError(auth); + return c.json(error.body, error.status); + } + const userId = auth.userId; const article = await fetchArticleById(c.req.param('id'), userId); if (!article) return c.json({ code: 'NOT_FOUND', message: 'Saved item not found.' }, 404); const item = { @@ -57,8 +80,12 @@ mcpReads.get('/reading/:id', async (c) => { }); mcpReads.get('/collections', async (c) => { - const userId = await ownerId(c.req.raw.headers); - if (!userId) return c.json({ code: 'UNAUTHORIZED', message: 'Read credential required.' }, 401); + const auth = await ownerId(c.req.raw.headers, c.env); + if (auth.status !== 'authorized') { + const error = authError(auth); + return c.json(error.body, error.status); + } + const userId = auth.userId; const limit = Math.max(1, pageValue(c.req.query('limit'), 10, 50)); const offset = pageValue(c.req.query('offset'), 0, 1_000_000); const lists = await fetchLists(userId); diff --git a/wrangler.toml b/wrangler.toml index b3bedb3..6916134 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -27,6 +27,8 @@ cpu_ms = 30000 NODE_ENV = "production" AI_BASE_URL = "https://ai-gateway.sassmaker.com/v1" BETTER_AUTH_URL = "https://read.significanthobbies.com" +AUTH0_ISSUER = "https://dev-0suel086hm1blvg7.us.auth0.com/" +AUTH0_MCP_AUDIENCE = "https://mcp.significanthobbies.com/reader/mcp" [[r2_buckets]] binding = "PDFS_BUCKET"