Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/lib/auth-api.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
Expand All @@ -16,6 +26,30 @@ export async function getApiKeyUserId(headers: Headers): Promise<string | null>
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<McpAuthResult> {
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.
*
Expand Down
75 changes: 75 additions & 0 deletions src/lib/auth0-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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();
}
});
});
118 changes: 118 additions & 0 deletions src/lib/auth0-mcp.ts
Original file line number Diff line number Diff line change
@@ -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<typeof verifyWithJwks>[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<string | null> {
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<string | null> {
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;
}
2 changes: 2 additions & 0 deletions src/lib/worker-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 14 additions & 6 deletions src/worker/routes/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
});

Expand Down
45 changes: 36 additions & 9 deletions src/worker/routes/mcp.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<ReturnType<typeof ownerId>>) {
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'),
Expand All @@ -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 = {
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down