fix: allow oauth login without workspace payload
This commit is contained in:
@@ -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}的组织空间`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user