fix(auth): route session lifecycle through Square
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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<LogoutInput>(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}`,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user