Skip to content

Commit 2d50aff

Browse files
hotlongCopilot
andcommitted
fix(cloud): re-seed SSO redirect_uris on hostname change to prevent INVALID_CALLBACK_URL
When a user changed an environment's hostname via the change_hostname action, the project's sys_oauth_application row was left holding the OLD hostname in its redirect_uris whitelist. The next OAuth2 flow from the per-env runtime to cloud then failed with: {"message":"Invalid callbackURL","code":"INVALID_CALLBACK_URL"} …because cloud's better-auth oauth-provider rejected the new `https://<new-hostname>/api/v1/auth/oauth2/callback/<provider>` URI. Fix: extract the post-provision SSO seed block into a reusable `reseedPlatformSsoForHostname()` helper and invoke it from BOTH change_hostname paths (the action-dispatch handler used by Cloud Control's UI button AND the explicit `/cloud/environments/:id/change-hostname` REST route). The existing `seedPlatformSsoClient()` is already designed to MERGE new redirect URIs into the existing row (it never drops old ones), so a rename is safe even if the user later moves back. trustedOrigins already accepts `https://*.objectos.app` via OS_TRUSTED_ORIGINS, so CSRF is unaffected — only the per-client redirect_uris whitelist needed updating. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 563b085 commit 2d50aff

1 file changed

Lines changed: 66 additions & 0 deletions

File tree

packages/services/service-cloud/src/routes/project-lifecycle.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,65 @@ function readActorIdFromHeaders(req: any): string | undefined {
3939

4040
function nowIso() { return new Date().toISOString(); }
4141

42+
/**
43+
* Best-effort re-seed of the per-project SSO client's `redirect_uris`.
44+
*
45+
* Called whenever a project's hostname changes (initial provision or
46+
* subsequent `change_hostname`) so that the cloud OAuth2 provider accepts
47+
* the new `https://<hostname>/api/v1/auth/oauth2/callback/<provider>`
48+
* callback. Without this, OAuth flows from a renamed environment fail
49+
* with `INVALID_CALLBACK_URL` because the client's stored whitelist
50+
* still references the OLD hostname.
51+
*
52+
* Failures are logged but never thrown — a missing SSO row should NOT
53+
* block hostname changes (the user can always re-run the action).
54+
*/
55+
async function reseedPlatformSsoForHostname(
56+
getDriver: () => Promise<any>,
57+
projectId: string,
58+
hostname: string,
59+
): Promise<void> {
60+
try {
61+
const baseSecret = (process.env.OS_AUTH_SECRET ?? process.env.AUTH_SECRET ?? '').trim();
62+
if (!baseSecret) {
63+
console.warn('[ProjectLifecycle] OS_AUTH_SECRET not set — skipping SSO re-seed', { projectId });
64+
return;
65+
}
66+
const driver = await getDriver();
67+
if (!driver) return;
68+
const qlAdapter = {
69+
find: async (object: string, q: any, _opts?: any) => {
70+
return await (driver.find as any)(object, q);
71+
},
72+
insert: async (object: string, data: any, _opts?: any) => {
73+
return await (driver.create as any)(object, data);
74+
},
75+
update: async (object: string, data: any, where: any, _opts?: any) => {
76+
const id = where?.id;
77+
if (id) {
78+
return await (driver.update as any)(object, id, data);
79+
}
80+
const rows = await (driver.find as any)(object, { where, limit: 1 });
81+
const list = Array.isArray(rows) ? rows : Array.isArray((rows as any)?.records) ? (rows as any).records : [];
82+
const row = list[0];
83+
if (row?.id) {
84+
return await (driver.update as any)(object, row.id, data);
85+
}
86+
},
87+
};
88+
const { seedPlatformSsoClient } = await import('@objectstack/runtime');
89+
await seedPlatformSsoClient({
90+
ql: qlAdapter as any,
91+
projectId,
92+
hostname,
93+
baseSecret,
94+
logger: console,
95+
});
96+
} catch (ssoErr: any) {
97+
console.warn('[ProjectLifecycle] platform SSO re-seed failed (non-fatal):', ssoErr?.message ?? ssoErr);
98+
}
99+
}
100+
42101
/**
43102
* Resolve the new hostname for a change-hostname call.
44103
*
@@ -360,6 +419,9 @@ export function registerProjectLifecycleRoutes(server: IHttpServer, deps: Packag
360419

361420
const success = await patchProject(projectId, { hostname, console_url: `https://${hostname}/_console`, api_base_url: `https://${hostname}/api/v1` });
362421
if (!success) return res.status(500).json(fail('Failed to persist update', 500));
422+
// Re-seed SSO client so OAuth callbacks at the new hostname pass
423+
// the cloud OAuth2 provider's redirect-URI whitelist.
424+
await reseedPlatformSsoForHostname(getDriver, projectId, hostname);
363425
return res.json(ok({ projectId, hostname }));
364426
});
365427

@@ -477,6 +539,10 @@ export function registerProjectLifecycleRoutes(server: IHttpServer, deps: Packag
477539
}
478540
const success = await patchProject(projectId, { hostname, console_url: `https://${hostname}/_console`, api_base_url: `https://${hostname}/api/v1` });
479541
if (!success) return res.status(500).json(fail('Failed to persist update', 500));
542+
// Re-seed the project's SSO client so the cloud OAuth2 provider
543+
// accepts callbacks at the new hostname (otherwise the per-env
544+
// runtime gets `INVALID_CALLBACK_URL` on the next login).
545+
await reseedPlatformSsoForHostname(getDriver, projectId, hostname);
480546
return res.json(ok({ projectId, hostname }));
481547
};
482548

0 commit comments

Comments
 (0)