From 3b252250cd95d59f1ac27c1b9fd5a965d9e46653 Mon Sep 17 00:00:00 2001 From: inman Date: Wed, 29 Apr 2026 14:33:41 +0800 Subject: [PATCH] fix: allow oauth login without workspace payload --- electron/yinian/http-control-plane.ts | 56 +++++++++++++++---- tests/unit/yinian-control-plane.test.ts | 71 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/electron/yinian/http-control-plane.ts b/electron/yinian/http-control-plane.ts index 9ff1915..8f3bb0d 100644 --- a/electron/yinian/http-control-plane.ts +++ b/electron/yinian/http-control-plane.ts @@ -182,7 +182,7 @@ export class HttpYinianControlPlane implements YinianControlPlane { random_str: input.randomStr, }); - return this.applyLoginResponse(response); + return this.applyLoginResponse(response, input.account); } async logout(): Promise { @@ -315,12 +315,12 @@ export class HttpYinianControlPlane implements YinianControlPlane { return this.storage.getSkillRegistry(hotelId); } - private async applyLoginResponse(response: JsonObject): Promise { + private async applyLoginResponse(response: JsonObject, accountHint?: string): Promise { this.accessToken = readString(response, 'accessToken', 'access_token'); this.refreshToken = readString(response, 'refreshToken', 'refresh_token'); if (!this.accessToken) throw new Error('服务端未返回 access token'); - const session = this.normalizeSessionFromPayload(response) ?? await this.getSessionState(); + const session = this.normalizeSessionFromPayload(response, accountHint) ?? await this.getSessionState(); if (!session.authenticated) throw new Error('登录成功但未获取到工作空间信息'); this.currentHotelId = session.currentHotelId; await this.persistSession(session); @@ -367,10 +367,15 @@ export class HttpYinianControlPlane implements YinianControlPlane { }); } - private normalizeSessionFromPayload(payload: JsonObject): YinianAuthSession | null { - const sessionPayload = readObject(payload, 'session') ?? payload; - const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo'); - if (!userValue) return null; + private normalizeSessionFromPayload(payload: JsonObject, accountHint?: string): YinianAuthSession | null { + const tokenPayload = this.accessToken ? parseJwtPayload(this.accessToken) : undefined; + const sessionPayload = { + ...(tokenPayload ?? {}), + ...payload, + ...(readObject(payload, 'session') ?? {}), + }; + const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo') + ?? extractUserPayload(sessionPayload, accountHint); const user = normalizeUser(userValue); const hotelsValue = readArray(sessionPayload, 'hotels', 'workspaces', 'businesses', 'tenants'); @@ -516,6 +521,20 @@ function createRandomString(): string { return randomUUID(); } +function parseJwtPayload(token: string): JsonObject | undefined { + const [, rawPayload] = token.split('.'); + if (!rawPayload) return undefined; + + try { + const normalized = rawPayload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as unknown; + return isObject(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + function buildScopedEndpoint(endpoint: string, workspaceId: string): string { const [rawPath, rawQuery = ''] = endpoint.split('?'); const path = rawPath @@ -563,21 +582,36 @@ function normalizeImageCaptcha(value: JsonObject, fallbackRandomStr: string): Yi function normalizeUser(value: unknown): YinianUser { const object = isObject(value) ? value : {}; return { - id: readString(object, 'id', 'userId', 'user_id') ?? 'user_unknown', - name: readString(object, 'name', 'username', 'nickname') ?? '未命名用户', - phone: readString(object, 'phone'), + id: readString(object, 'id', 'userId', 'user_id', 'userIdStr', 'user_id_str', 'sub', 'user_name') ?? 'user_unknown', + name: readString(object, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name', 'account') ?? '未命名用户', + phone: readString(object, 'phone', 'mobile', 'phoneNumber', 'phone_number'), email: readString(object, 'email'), avatar: readString(object, 'avatar'), }; } +function extractUserPayload(sessionPayload: JsonObject, accountHint?: string): JsonObject { + const account = accountHint?.trim() + || readString(sessionPayload, 'username', 'userName', 'user_name', 'account', 'phone', 'mobile') + || '当前用户'; + return { + id: readString(sessionPayload, 'userId', 'user_id', 'id', 'sub', 'user_name') ?? account, + name: readString(sessionPayload, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name') ?? account, + phone: readString(sessionPayload, 'phone', 'mobile', 'phoneNumber', 'phone_number'), + email: readString(sessionPayload, 'email'), + avatar: readString(sessionPayload, 'avatar'), + tenantId: readString(sessionPayload, 'tenantId', 'tenant_id', 'deptId', 'dept_id', 'orgId', 'org_id'), + }; +} + function createDefaultWorkspace(sessionPayload: JsonObject, userPayload: JsonObject, user: YinianUser): YinianHotel { const tenantId = readString(userPayload, 'tenantId', 'tenant_id') ?? readString(sessionPayload, 'tenantId', 'tenant_id') + ?? readString(sessionPayload, 'deptId', 'dept_id', 'orgId', 'org_id') ?? 'default'; return { id: `service_${tenantId}`, - name: readString(sessionPayload, 'tenantName', 'tenant_name', 'workspaceName', 'workspace_name') + name: readString(sessionPayload, 'tenantName', 'tenant_name', 'workspaceName', 'workspace_name', 'deptName', 'dept_name', 'orgName', 'org_name') ?? `${user.name}的组织空间`, }; } diff --git a/tests/unit/yinian-control-plane.test.ts b/tests/unit/yinian-control-plane.test.ts index 1998088..5cc848e 100644 --- a/tests/unit/yinian-control-plane.test.ts +++ b/tests/unit/yinian-control-plane.test.ts @@ -501,6 +501,77 @@ describe('HttpYinianControlPlane', () => { expect((await storage.getSession())?.refreshToken).toBe('refresh_onefeel'); }); + it('creates a local organization session when oauth token response has no profile payload', async () => { + const storage = createMemoryYinianStorage(); + const controlPlane = new HttpYinianControlPlane({ + apiBaseUrl: 'https://api.example.test', + storage, + }); + installFetchRoutes({ + 'POST /auth/oauth2/token': { + access_token: 'access_without_profile', + refresh_token: 'refresh_without_profile', + expires_in: 3600, + }, + }); + + const session = await controlPlane.loginWithPassword({ + account: 'tmt1', + password: '123456', + }); + + expect(session.authenticated).toBe(true); + expect(session.user).toMatchObject({ + id: 'tmt1', + name: 'tmt1', + }); + expect(session.hotels).toEqual([ + expect.objectContaining({ + id: 'service_default', + name: 'tmt1的组织空间', + }), + ]); + expect(session.currentHotelId).toBe('service_default'); + expect((await storage.getSession())?.refreshToken).toBe('refresh_without_profile'); + }); + + it('uses JWT claims as a fallback profile for oauth token responses', async () => { + const storage = createMemoryYinianStorage(); + const controlPlane = new HttpYinianControlPlane({ + apiBaseUrl: 'https://api.example.test', + storage, + }); + const claims = Buffer.from(JSON.stringify({ + user_id: 'jwt-user-1', + user_name: 'jwt-user', + tenant_id: 'tenant-9', + tenant_name: 'JWT 组织', + })).toString('base64url'); + installFetchRoutes({ + 'POST /auth/oauth2/token': { + access_token: `header.${claims}.signature`, + refresh_token: 'refresh_jwt', + expires_in: 3600, + }, + }); + + const session = await controlPlane.loginWithPassword({ + account: 'fallback-account', + password: '123456', + }); + + expect(session.user).toMatchObject({ + id: 'jwt-user-1', + name: 'jwt-user', + }); + expect(session.hotels).toEqual([ + expect.objectContaining({ + id: 'service_tenant-9', + name: 'JWT 组织', + }), + ]); + }); + it('reports HTTP server status through health endpoint', async () => { const controlPlane = new HttpYinianControlPlane({ apiBaseUrl: 'https://api.example.test',