fix: allow oauth login without workspace payload

This commit is contained in:
inman
2026-04-29 14:33:41 +08:00
parent 85b1863bd7
commit 3b252250cd
2 changed files with 116 additions and 11 deletions

View File

@@ -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<YinianSessionState> {
@@ -315,12 +315,12 @@ export class HttpYinianControlPlane implements YinianControlPlane {
return this.storage.getSkillRegistry(hotelId);
}
private async applyLoginResponse(response: JsonObject): Promise<YinianAuthSession> {
private async applyLoginResponse(response: JsonObject, accountHint?: string): Promise<YinianAuthSession> {
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}的组织空间`,
};
}

View File

@@ -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',