diff --git a/.project-docs/30-worklog/tasks/20260819-square-auth-proxy-client-8c4f2a.md b/.project-docs/30-worklog/tasks/20260819-square-auth-proxy-client-8c4f2a.md new file mode 100644 index 0000000..92a2ac6 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260819-square-auth-proxy-client-8c4f2a.md @@ -0,0 +1,57 @@ +# Task: Route Makelore auth lifecycle through Works Square + +## Identity + +- Task ID: 20260819-square-auth-proxy-client-8c4f2a +- Mode: Feature +- Branch: codex/20260819-square-auth-proxy-client-8c4f2a-square-auth-proxy-client +- Worktree: D:\Datas\OthersProjects\makelore-square-auth-proxy-8c4f2a +- Base commit: 1907924203a1a998182c8e44e8884682ef216d95 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Route Works Square access-token refresh through `POST /api/auth/refresh` instead of calling the one-feel OAuth endpoint from Electron Main. +- Route remote logout through `POST /api/auth/logout` on Works Square while preserving Main-owned session cleanup behavior. +- Update focused session and auth-route regression tests, including rotated refresh-token persistence. + +## Intent And Constraints + +- Login, refresh, and logout must share the Works Square authentication boundary. +- Do not embed the deployed `custom` OAuth client secret in Makelore. +- Preserve Main ownership of tokens, the seven-day inactivity policy, refresh single-flight behavior, fail-closed persistence semantics, and local runtime cleanup. +- Do not change unrelated browser/OAuth-provider integrations. + +## Outcome + +- Electron Main now refreshes managed Works Square sessions with `POST https://square.nianxx.cn/api/auth/refresh` and a JSON refresh-token payload. It no longer sends an OAuth Basic credential or calls the one-feel token endpoint directly. +- Main-owned logout now calls `POST https://square.nianxx.cn/api/auth/logout` with the current Main access token, while retaining the existing local session, secure persistence, event-session, provider-key, and runtime cleanup behavior. +- Removed the now-unused Electron auth configuration module that embedded the legacy `app` client secret and password encryption key. +- Preserved refresh-token rotation, single-flight behavior, terminal 400/401 clearing, transient-error retention, and persistence-before-return semantics. + +## Verification + +- Red phase: focused tests failed in five expected places because refresh and logout still targeted `biz.nianxx.cn` with the legacy method/credential contract. +- `pnpm exec vitest run tests/unit/works-square-session.test.ts tests/unit/auth-routes.test.ts`: 56 passed. +- Focused session/auth compatibility suite: 86 passed across five test files. +- Full `pnpm exec vitest run`: 184 test files and 2190 tests passed. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: passed with 0 errors and 7 pre-existing React warnings in unrelated files. +- `pnpm run build:vite`: passed for Renderer, Electron Main, preload, and utility worker. +- Full `pnpm run build` stopped before compilation because the required Learning Player artifact/source was not supplied; the subsequent direct Vite production build passed. +- `git diff --check`: passed. Source scan found no direct one-feel refresh/logout URL, Basic auth header, embedded client secret, or deleted auth-config import under `electron/`. + +## Follow-ups + +- Deploy the Works Square refresh/logout endpoints before distributing a Makelore build containing this client change. +- Supply the separately managed Learning Player artifact when producing the final packaged installer. + +## Promotion Candidates + +- Target canonical document: `.project-docs/20-architecture/data-flow.md` and the authentication decision index. + Proposal: record Works Square as the required boundary for the complete desktop authentication lifecycle (login, refresh, and logout), with confidential OAuth clients owned only by Works Square. + Evidence: focused request-contract tests, full 2190-test client suite, typecheck, lint, and production Vite build. + Future impact: new desktop authentication operations must be added to Square rather than embedding service credentials or direct one-feel calls in Makelore. + Semantic conflicts: none; this completes the proxy work explicitly deferred by the integrated native-login task. + Human confirmation required: no; the user explicitly selected the unified Square boundary. diff --git a/electron/api/auth-config.ts b/electron/api/auth-config.ts deleted file mode 100644 index 7f1e568..0000000 --- a/electron/api/auth-config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - NIANCODE_AUTH_CLIENT_ID, - NIANCODE_AUTH_GATEWAY_URL, - NIANCODE_AUTH_SCOPE, -} from '../../shared/auth-public'; - -export const NIANCODE_AUTH_CLIENT_SECRET = 'app'; -export const NIANCODE_AUTH_PASSWORD_ENCODE_KEY = 'thanks,pig4cloud'; - -export const NIANCODE_AUTH_CONFIG = { - gatewayAuthUrl: NIANCODE_AUTH_GATEWAY_URL, - clientId: NIANCODE_AUTH_CLIENT_ID, - clientSecret: NIANCODE_AUTH_CLIENT_SECRET, - passwordEncodeKey: NIANCODE_AUTH_PASSWORD_ENCODE_KEY, - scope: NIANCODE_AUTH_SCOPE, -} as const; diff --git a/electron/api/routes/auth.ts b/electron/api/routes/auth.ts index c3574a8..4679d93 100644 --- a/electron/api/routes/auth.ts +++ b/electron/api/routes/auth.ts @@ -2,7 +2,6 @@ import type { IncomingMessage, ServerResponse } from 'http'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; -import { NIANCODE_AUTH_CONFIG } from '../auth-config'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { clearWorksSquareSession, @@ -92,14 +91,6 @@ function withoutRefreshToken(payload: unknown): unknown { return publicPayload; } -function normalizeAuthBase(): string { - const authBase = NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, ''); - if (!/^https?:\/\//i.test(authBase)) { - throw new Error('authBase must start with http:// or https://'); - } - return authBase; -} - function normalizeWorksBase(value = WORKS_SQUARE_CONFIG.apiBaseUrl): string { const apiBase = value.replace(/\/+$/, ''); if (!/^https?:\/\//i.test(apiBase)) { @@ -652,7 +643,6 @@ async function handleLogout( await parseJsonBody(req), ['accessToken'], ); - const authBase = normalizeAuthBase(); const rendererAccessToken = readOptionalTrimmedString(body.accessToken); const accessToken = getWorksSquareSessionSnapshot()?.accessToken ?? readRequiredString(rendererAccessToken, 'accessToken'); @@ -674,8 +664,8 @@ async function handleLogout( let response: Response; try { - response = await proxyAwareFetch(`${authBase}/token/logout`, { - method: 'DELETE', + response = await proxyAwareFetch(createWorksUrl('/api/auth/logout').toString(), { + method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, }, diff --git a/electron/services/works-square-session.ts b/electron/services/works-square-session.ts index cbfe6e5..1208573 100644 --- a/electron/services/works-square-session.ts +++ b/electron/services/works-square-session.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto'; -import { NIANCODE_AUTH_CONFIG } from '../api/auth-config'; +import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch'; import { logger } from '../utils/logger'; +import { NIANCODE_AUTH_GATEWAY_URL } from '../../shared/auth-public'; import { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session'; const TOKEN_REFRESH_SKEW_MS = 30_000; @@ -149,10 +150,6 @@ function expiresAtFromExpiresIn(expiresIn: unknown, nowMs = Date.now()): number return Number.isFinite(seconds) && seconds > 0 ? nowMs + seconds * 1000 : null; } -function createBasicAuthHeader(clientId: string, clientSecret: string): string { - return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; -} - function toPublicSnapshot( session: StoredWorksSquareSession | null, ): WorksSquareSessionSnapshot | null { @@ -327,7 +324,7 @@ async function createElectronSessionPersistence( if (!record) return null; if ( record.version !== SESSION_STORE_SCHEMA_VERSION - || record.authBase !== NIANCODE_AUTH_CONFIG.gatewayAuthUrl + || record.authBase !== NIANCODE_AUTH_GATEWAY_URL ) { store.delete('record'); return null; @@ -344,7 +341,7 @@ async function createElectronSessionPersistence( const encrypted = safeStorage.encryptString(JSON.stringify(session)); store.set('record', { version: SESSION_STORE_SCHEMA_VERSION, - authBase: NIANCODE_AUTH_CONFIG.gatewayAuthUrl, + authBase: NIANCODE_AUTH_GATEWAY_URL, ciphertext: encrypted.toString('base64'), }); }, @@ -723,22 +720,13 @@ async function refreshWorksSquareSession( const fetchImpl = options.fetchImpl ?? proxyAwareFetch; const nowMs = options.nowMs ?? Date.now(); - const body = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }); + const body = JSON.stringify({ refresh_token: session.refreshToken }); const { response, payload } = await runWithDeadline(async (signal) => { const response = await fetchImpl( - `${NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, '')}/oauth2/token`, + `${WORKS_SQUARE_CONFIG.apiBaseUrl.replace(/\/+$/, '')}/api/auth/refresh`, { method: 'POST', - headers: { - Authorization: createBasicAuthHeader( - NIANCODE_AUTH_CONFIG.clientId, - NIANCODE_AUTH_CONFIG.clientSecret, - ), - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: { 'Content-Type': 'application/json' }, body, signal, }, diff --git a/tests/unit/auth-routes.test.ts b/tests/unit/auth-routes.test.ts index 44493a1..58e29d5 100644 --- a/tests/unit/auth-routes.test.ts +++ b/tests/unit/auth-routes.test.ts @@ -734,9 +734,10 @@ describe('auth host api routes', () => { ); expect(refreshResponse.statusCode).toBe(200); - const [, refreshInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(String(refreshInit.body)).toBe( - 'grant_type=refresh_token&refresh_token=main-refresh-r1', + const [refreshUrl, refreshInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(refreshUrl).toBe('https://square.nianxx.cn/api/auth/refresh'); + expect(refreshInit.body).toBe( + JSON.stringify({ refresh_token: 'main-refresh-r1' }), ); expect(String(refreshInit.body)).not.toContain('renderer-refresh-r0'); }); @@ -904,8 +905,9 @@ describe('auth host api routes', () => { expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledWith( - 'https://biz.nianxx.cn/auth/token/logout', + 'https://square.nianxx.cn/api/auth/logout', expect.objectContaining({ + method: 'POST', headers: { Authorization: 'Bearer main-current-access-token' }, }), ); @@ -1034,8 +1036,8 @@ describe('auth host api routes', () => { ); expect(fetchMock).toHaveBeenCalledWith( - 'https://biz.nianxx.cn/auth/token/logout', - expect.objectContaining({ method: 'DELETE' }), + 'https://square.nianxx.cn/api/auth/logout', + expect.objectContaining({ method: 'POST' }), ); expect(response.statusCode).toBe(500); }); diff --git a/tests/unit/works-square-session.test.ts b/tests/unit/works-square-session.test.ts index 41e74c1..7d1bc8e 100644 --- a/tests/unit/works-square-session.test.ts +++ b/tests/unit/works-square-session.test.ts @@ -71,18 +71,13 @@ describe('works-square-session service', () => { await expect(getValidWorksSquareAccessToken({ fetchImpl })).resolves.toBe('new-access-token'); expect(fetchImpl).toHaveBeenCalledWith( - 'https://biz.nianxx.cn/auth/oauth2/token', + 'https://square.nianxx.cn/api/auth/refresh', expect.objectContaining({ method: 'POST', - headers: expect.objectContaining({ - Authorization: `Basic ${Buffer.from('app:app').toString('base64')}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }), - body: expect.any(URLSearchParams), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: 'old-refresh-token' }), }), ); - const body = fetchImpl.mock.calls[0][1].body as URLSearchParams; - expect(String(body)).toBe('grant_type=refresh_token&refresh_token=old-refresh-token'); expect(getWorksSquareSessionSnapshot()).toMatchObject({ accessToken: 'new-access-token', tokenType: 'Bearer', @@ -453,8 +448,9 @@ describe('works-square-session service', () => { ); await getValidWorksSquareAccessToken({ fetchImpl: secondFetch, forceRefresh: true }); - const body = secondFetch.mock.calls[0][1].body as URLSearchParams; - expect(String(body)).toContain('refresh_token=rotated-refresh-token'); + expect(secondFetch.mock.calls[0][1].body).toBe(JSON.stringify({ + refresh_token: 'rotated-refresh-token', + })); }); it('uses user_id ahead of username for a stable opaque account partition', () => {