mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat(desktop): add /api/haha-oauth REST endpoints
Endpoints:
- POST /api/haha-oauth/start — 生成 PKCE,返回 authorize URL
- GET /api/haha-oauth/callback — 浏览器 redirect 到此,完成 token exchange,
渲染 success/fail HTML 页(自动关闭)
- GET /api/haha-oauth — 查询登录状态(不回传 token 本体)
- DELETE /api/haha-oauth — 登出,清除 token 文件
前端用 start 拿到 authorizeUrl → shell.open(浏览器) → 浏览器走 OAuth → redirect
到 callback → callback 完成后显示 '登录成功,可关闭窗口' 页面。前端并行轮询
/api/haha-oauth 检测 loggedIn=true 后刷新 UI。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a1b2530fab
commit
142eb9cd11
125
src/server/__tests__/haha-oauth-api.test.ts
Normal file
125
src/server/__tests__/haha-oauth-api.test.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
120
src/server/api/haha-oauth.ts
Normal file
120
src/server/api/haha-oauth.ts
Normal file
@ -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<Response> {
|
||||
const action = segments[2] // segments: ['api', 'haha-oauth', <action?>]
|
||||
|
||||
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 `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Login Success</title>
|
||||
<style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#333}.card{text-align:center;padding:40px;background:white;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.06)}h1{color:#16a34a;margin:0 0 12px}p{color:#666}</style>
|
||||
</head><body><div class="card"><h1>✓ Login Successful</h1><p>You can close this window and return to Claude Code Haha.</p></div>
|
||||
<script>setTimeout(() => window.close(), 1500)</script>
|
||||
</body></html>`
|
||||
}
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Login Failed</title>
|
||||
<style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#333}.card{text-align:center;padding:40px;background:white;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.06)}h1{color:#dc2626;margin:0 0 12px}pre{color:#666;white-space:pre-wrap;word-break:break-word;text-align:left;background:#f5f5f5;padding:12px;border-radius:6px}</style>
|
||||
</head><body><div class="card"><h1>✗ Login Failed</h1><pre>${escapeHtml(errorMsg ?? 'Unknown error')}</pre></div>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@ -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<Response> {
|
||||
const path = url.pathname
|
||||
@ -66,6 +67,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'providers':
|
||||
return handleProvidersApi(req, url, segments)
|
||||
|
||||
case 'haha-oauth':
|
||||
return handleHahaOAuthApi(req, url, segments)
|
||||
|
||||
case 'adapters':
|
||||
return handleAdaptersApi(req, url, segments)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user