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,
|
random_str: input.randomStr,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.applyLoginResponse(response);
|
return this.applyLoginResponse(response, input.account);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(): Promise<YinianSessionState> {
|
async logout(): Promise<YinianSessionState> {
|
||||||
@@ -315,12 +315,12 @@ export class HttpYinianControlPlane implements YinianControlPlane {
|
|||||||
return this.storage.getSkillRegistry(hotelId);
|
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.accessToken = readString(response, 'accessToken', 'access_token');
|
||||||
this.refreshToken = readString(response, 'refreshToken', 'refresh_token');
|
this.refreshToken = readString(response, 'refreshToken', 'refresh_token');
|
||||||
if (!this.accessToken) throw new Error('服务端未返回 access 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('登录成功但未获取到工作空间信息');
|
if (!session.authenticated) throw new Error('登录成功但未获取到工作空间信息');
|
||||||
this.currentHotelId = session.currentHotelId;
|
this.currentHotelId = session.currentHotelId;
|
||||||
await this.persistSession(session);
|
await this.persistSession(session);
|
||||||
@@ -367,10 +367,15 @@ export class HttpYinianControlPlane implements YinianControlPlane {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeSessionFromPayload(payload: JsonObject): YinianAuthSession | null {
|
private normalizeSessionFromPayload(payload: JsonObject, accountHint?: string): YinianAuthSession | null {
|
||||||
const sessionPayload = readObject(payload, 'session') ?? payload;
|
const tokenPayload = this.accessToken ? parseJwtPayload(this.accessToken) : undefined;
|
||||||
const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo');
|
const sessionPayload = {
|
||||||
if (!userValue) return null;
|
...(tokenPayload ?? {}),
|
||||||
|
...payload,
|
||||||
|
...(readObject(payload, 'session') ?? {}),
|
||||||
|
};
|
||||||
|
const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo')
|
||||||
|
?? extractUserPayload(sessionPayload, accountHint);
|
||||||
|
|
||||||
const user = normalizeUser(userValue);
|
const user = normalizeUser(userValue);
|
||||||
const hotelsValue = readArray(sessionPayload, 'hotels', 'workspaces', 'businesses', 'tenants');
|
const hotelsValue = readArray(sessionPayload, 'hotels', 'workspaces', 'businesses', 'tenants');
|
||||||
@@ -516,6 +521,20 @@ function createRandomString(): string {
|
|||||||
return randomUUID();
|
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 {
|
function buildScopedEndpoint(endpoint: string, workspaceId: string): string {
|
||||||
const [rawPath, rawQuery = ''] = endpoint.split('?');
|
const [rawPath, rawQuery = ''] = endpoint.split('?');
|
||||||
const path = rawPath
|
const path = rawPath
|
||||||
@@ -563,21 +582,36 @@ function normalizeImageCaptcha(value: JsonObject, fallbackRandomStr: string): Yi
|
|||||||
function normalizeUser(value: unknown): YinianUser {
|
function normalizeUser(value: unknown): YinianUser {
|
||||||
const object = isObject(value) ? value : {};
|
const object = isObject(value) ? value : {};
|
||||||
return {
|
return {
|
||||||
id: readString(object, 'id', 'userId', 'user_id') ?? 'user_unknown',
|
id: readString(object, 'id', 'userId', 'user_id', 'userIdStr', 'user_id_str', 'sub', 'user_name') ?? 'user_unknown',
|
||||||
name: readString(object, 'name', 'username', 'nickname') ?? '未命名用户',
|
name: readString(object, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name', 'account') ?? '未命名用户',
|
||||||
phone: readString(object, 'phone'),
|
phone: readString(object, 'phone', 'mobile', 'phoneNumber', 'phone_number'),
|
||||||
email: readString(object, 'email'),
|
email: readString(object, 'email'),
|
||||||
avatar: readString(object, 'avatar'),
|
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 {
|
function createDefaultWorkspace(sessionPayload: JsonObject, userPayload: JsonObject, user: YinianUser): YinianHotel {
|
||||||
const tenantId = readString(userPayload, 'tenantId', 'tenant_id')
|
const tenantId = readString(userPayload, 'tenantId', 'tenant_id')
|
||||||
?? readString(sessionPayload, 'tenantId', 'tenant_id')
|
?? readString(sessionPayload, 'tenantId', 'tenant_id')
|
||||||
|
?? readString(sessionPayload, 'deptId', 'dept_id', 'orgId', 'org_id')
|
||||||
?? 'default';
|
?? 'default';
|
||||||
return {
|
return {
|
||||||
id: `service_${tenantId}`,
|
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}的组织空间`,
|
?? `${user.name}的组织空间`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,6 +501,77 @@ describe('HttpYinianControlPlane', () => {
|
|||||||
expect((await storage.getSession())?.refreshToken).toBe('refresh_onefeel');
|
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 () => {
|
it('reports HTTP server status through health endpoint', async () => {
|
||||||
const controlPlane = new HttpYinianControlPlane({
|
const controlPlane = new HttpYinianControlPlane({
|
||||||
apiBaseUrl: 'https://api.example.test',
|
apiBaseUrl: 'https://api.example.test',
|
||||||
|
|||||||
Reference in New Issue
Block a user