fix(cloud): provision login workspace just in time (#2505)

* fix(cloud): provision login workspace just in time

* fix(oauth): send callback URI during code exchange

* fix(oauth): preserve callback URI through browser exchange

* fix(oauth): negotiate redirect-bound codes

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-09-03 23:14:44 +08:00
committed by GitHub
parent ab52684a01
commit b44b8f474d
11 changed files with 407 additions and 165 deletions
+15 -3
View File
@@ -43,17 +43,24 @@ const pendingSpaceOAuthLogins = new Map<
function getOrCreateSpaceOAuthLoginPromise(
authCode: string,
state: string,
redirectUri: string,
workspaceUuid?: string,
launchAssertion?: string,
): Promise<SpaceOAuthLoginResult> {
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
const requestKey = `${authCode}:${state}:${redirectUri}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
if (pendingRequest) {
return pendingRequest;
}
const requestPromise = httpClient
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
.exchangeSpaceOAuthCode(
authCode,
state,
redirectUri,
workspaceUuid,
launchAssertion,
)
.finally(() => {
pendingSpaceOAuthLogins.delete(requestKey);
});
@@ -95,6 +102,7 @@ function SpaceOAuthCallbackContent() {
const response = await getOrCreateSpaceOAuthLoginPromise(
authCode,
state,
`${window.location.origin}/auth/space/callback`,
workspaceUuid,
launchAssertion,
);
@@ -195,7 +203,11 @@ function SpaceOAuthCallbackContent() {
async (authCode: string, state: string) => {
setIsProcessing(true);
try {
const response = await httpClient.bindSpaceAccount(authCode, state);
const response = await httpClient.bindSpaceAccount(
authCode,
state,
`${window.location.origin}/auth/space/callback?mode=bind`,
);
if (!isMountedRef.current) {
return;
}
+6 -9
View File
@@ -1365,6 +1365,7 @@ export class BackendClient extends BaseHttpClient {
public async bindSpaceAccount(
code: string,
state: string,
redirectUri: string,
): Promise<{
token: string;
user: string;
@@ -1372,7 +1373,7 @@ export class BackendClient extends BaseHttpClient {
}> {
const response = await this.instance.post(
'/api/v1/user/bind-space',
{ code, state },
{ code, state, redirect_uri: redirectUri },
{ skipWorkspace: true } as RequestConfig,
);
if (response.data.code !== 0) {
@@ -1385,18 +1386,12 @@ export class BackendClient extends BaseHttpClient {
}
// ============ Space OAuth API (Redirect Flow) ============
public getSpaceAuthorizeUrl(
redirectUri: string,
options?: { cloudEntry?: boolean },
): Promise<{
public getSpaceAuthorizeUrl(redirectUri: string): Promise<{
authorize_url: string;
}> {
return this.get(
'/api/v1/user/space/authorize-url',
{
redirect_uri: redirectUri,
...(options?.cloudEntry ? { cloud_entry: '1' } : {}),
},
{ redirect_uri: redirectUri },
{ skipWorkspace: true },
);
}
@@ -1414,6 +1409,7 @@ export class BackendClient extends BaseHttpClient {
public async exchangeSpaceOAuthCode(
code: string,
state: string,
redirectUri: string,
workspaceUuid?: string,
launchAssertion?: string,
): Promise<{
@@ -1428,6 +1424,7 @@ export class BackendClient extends BaseHttpClient {
{
code,
state,
redirect_uri: redirectUri,
workspace_uuid: workspaceUuid,
launch_assertion: launchAssertion,
},
+1 -7
View File
@@ -202,13 +202,7 @@ export default function Login() {
try {
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback`;
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri, {
// Cloud Accounts must be launched from Space so a first visit can
// lazily create and project the personal Workspace. Invitation login
// remains on the OAuth callback path because it targets the invited
// Workspace instead.
cloudEntry: !getPendingInvitationToken(),
});
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
@@ -7,11 +7,13 @@ const source = fs.readFileSync(
'utf8',
);
test('normal Cloud login enters through the Space Workspace launcher', () => {
assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/);
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri,\s*\{/);
test('normal Cloud login uses the standard Space OAuth callback path', () => {
assert.doesNotMatch(source, /cloudEntry/);
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
});
test('invitation login remains on the OAuth callback path', () => {
assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/);
test('invitation login uses the same OAuth callback before accepting the invitation', () => {
assert.doesNotMatch(source, /cloudEntry/);
assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/);
assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/);
});