diff --git a/src/server/__tests__/haha-oauth-api.test.ts b/src/server/__tests__/haha-oauth-api.test.ts new file mode 100644 index 00000000..f2444cd0 --- /dev/null +++ b/src/server/__tests__/haha-oauth-api.test.ts @@ -0,0 +1,125 @@ +/** + * Integration tests for /api/haha-oauth/* endpoints. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import * as fs from 'fs/promises' +import * as path from 'path' +import * as os from 'os' +import { handleHahaOAuthApi } from '../api/haha-oauth.js' +import { hahaOAuthService } from '../services/hahaOAuthService.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-oauth-api-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir +} + +async function teardown() { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + await fs.rm(tmpDir, { recursive: true, force: true }) +} + +function buildReq( + method: string, + pathname: string, + body?: unknown, +): { req: Request; url: URL; segments: string[] } { + const url = new URL(`http://localhost:3456${pathname}`) + const req = new Request(url.toString(), { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) + const segments = url.pathname.split('/').filter(Boolean) + return { req, url, segments } +} + +describe('POST /api/haha-oauth/start', () => { + beforeEach(setup) + afterEach(teardown) + + test('returns authorize URL with PKCE challenge', async () => { + const { req, url, segments } = buildReq('POST', '/api/haha-oauth/start', { + serverPort: 54321, + }) + const res = await handleHahaOAuthApi(req, url, segments) + expect(res.status).toBe(200) + const data = (await res.json()) as { authorizeUrl: string; state: string } + expect(data.authorizeUrl).toContain('code_challenge_method=S256') + expect(data.authorizeUrl).toContain( + encodeURIComponent('http://localhost:54321/api/haha-oauth/callback'), + ) + expect(data.state).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + test('400 if serverPort missing', async () => { + const { req, url, segments } = buildReq('POST', '/api/haha-oauth/start', {}) + const res = await handleHahaOAuthApi(req, url, segments) + expect(res.status).toBe(400) + }) +}) + +describe('GET /api/haha-oauth/status', () => { + beforeEach(setup) + afterEach(teardown) + + test('returns loggedIn=false when no token file', async () => { + const { req, url, segments } = buildReq('GET', '/api/haha-oauth/status') + const res = await handleHahaOAuthApi(req, url, segments) + expect(res.status).toBe(200) + const data = (await res.json()) as { loggedIn: boolean } + expect(data.loggedIn).toBe(false) + }) + + test('returns loggedIn=true + metadata when token saved', async () => { + await hahaOAuthService.saveTokens({ + accessToken: 'sk-ant-oat01-xxx', + refreshToken: 'sk-ant-ort01-xxx', + expiresAt: Date.now() + 3600_000, + scopes: ['user:inference'], + subscriptionType: 'max', + }) + + const { req, url, segments } = buildReq('GET', '/api/haha-oauth/status') + const res = await handleHahaOAuthApi(req, url, segments) + expect(res.status).toBe(200) + const data = (await res.json()) as { + loggedIn: boolean + subscriptionType: string | null + scopes: string[] + } + expect(data.loggedIn).toBe(true) + expect(data.subscriptionType).toBe('max') + expect(data.scopes).toEqual(['user:inference']) + expect(JSON.stringify(data)).not.toContain('sk-ant-oat01') + expect(JSON.stringify(data)).not.toContain('sk-ant-ort01') + }) +}) + +describe('DELETE /api/haha-oauth', () => { + beforeEach(setup) + afterEach(teardown) + + test('clears token file', async () => { + await hahaOAuthService.saveTokens({ + accessToken: 'a', + refreshToken: null, + expiresAt: null, + scopes: [], + subscriptionType: null, + }) + + const { req, url, segments } = buildReq('DELETE', '/api/haha-oauth') + const res = await handleHahaOAuthApi(req, url, segments) + expect(res.status).toBe(200) + expect(await hahaOAuthService.loadTokens()).toBeNull() + }) +}) diff --git a/src/server/api/haha-oauth.ts b/src/server/api/haha-oauth.ts new file mode 100644 index 00000000..a39c34aa --- /dev/null +++ b/src/server/api/haha-oauth.ts @@ -0,0 +1,120 @@ +/** + * Haha OAuth REST API + * + * POST /api/haha-oauth/start — 生成 PKCE+state,返回 authorize URL + * GET /api/haha-oauth/callback — 用户浏览器 redirect 到此,完成 token 交换 + * GET /api/haha-oauth — 查询当前登录状态(不回传 token 本体) + * GET /api/haha-oauth/status — 同上(legacy path) + * DELETE /api/haha-oauth — 登出,删除 token 文件 + */ + +import { z } from 'zod' +import { hahaOAuthService } from '../services/hahaOAuthService.js' + +const StartRequestSchema = z.object({ + serverPort: z.number().int().positive(), +}) + +function html(body: string): Response { + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) +} + +export async function handleHahaOAuthApi( + req: Request, + url: URL, + segments: string[], +): Promise { + const action = segments[2] // segments: ['api', 'haha-oauth', ] + + if (action === 'start' && req.method === 'POST') { + let body: unknown + try { + body = await req.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + const parsed = StartRequestSchema.safeParse(body) + if (!parsed.success) { + return Response.json( + { error: 'serverPort (positive integer) required' }, + { status: 400 }, + ) + } + const session = hahaOAuthService.startSession({ + serverPort: parsed.data.serverPort, + }) + return Response.json({ + authorizeUrl: session.authorizeUrl, + state: session.state, + }) + } + + if (action === 'callback' && req.method === 'GET') { + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const error = url.searchParams.get('error') + + if (error) { + return html(renderCallbackPage(false, `OAuth provider returned: ${error}`)) + } + if (!code || !state) { + return html(renderCallbackPage(false, 'Missing code or state parameter')) + } + + try { + await hahaOAuthService.completeSession(code, state) + return html(renderCallbackPage(true, null)) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return html(renderCallbackPage(false, msg)) + } + } + + if ((action === undefined || action === 'status') && req.method === 'GET') { + const tokens = await hahaOAuthService.loadTokens() + if (!tokens) { + return Response.json({ loggedIn: false }) + } + return Response.json({ + loggedIn: true, + expiresAt: tokens.expiresAt, + scopes: tokens.scopes, + subscriptionType: tokens.subscriptionType, + }) + } + + if (action === undefined && req.method === 'DELETE') { + await hahaOAuthService.deleteTokens() + return Response.json({ ok: true }) + } + + return Response.json({ error: 'Not Found' }, { status: 404 }) +} + +function renderCallbackPage(success: boolean, errorMsg: string | null): string { + if (success) { + return ` +Login Success + +

✓ Login Successful

You can close this window and return to Claude Code Haha.

+ +` + } + return ` +Login Failed + +

✗ Login Failed

${escapeHtml(errorMsg ?? 'Unknown error')}
+` +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/src/server/router.ts b/src/server/router.ts index 427582fb..472808d6 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -16,6 +16,7 @@ import { handleProvidersApi } from './api/providers.js' import { handleAdaptersApi } from './api/adapters.js' import { handleSkillsApi } from './api/skills.js' import { handleComputerUseApi } from './api/computer-use.js' +import { handleHahaOAuthApi } from './api/haha-oauth.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -66,6 +67,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise