Skip to content

Commit 3c1eb12

Browse files
os-zhuangCopilot
andcommitted
feat(runtime/auth-proxy): add sso-handoff-issue + sso-exchange endpoints
env-runtime side of cloud's 'Open as Admin' flow. The two endpoints together let cloud's control plane (which has no in-process kernel-manager) mint a short-lived handoff token in the env's better-auth verification DB and have the env runtime exchange it for a real signed session cookie. - POST /api/v1/auth/sso-handoff-issue Bearer-authenticated by the shared OS_CLOUD_API_KEY. Writes a 60s verification row (sso-handoff:<token>) and returns the token. - GET /api/v1/auth/sso-exchange?token=<token>&next=/ Consumes the verification row, finds-or-creates the user by email, mints a fresh better-auth session, sets the signed session cookie and 302s to next (or /_console/system/profile?recovery_needed=true when the user has no credential account yet). The cloud-side caller (service-cloud sso_as_owner action) was previously also living in service-cloud's local AuthProxyPlugin copy. Centralising it in framework lets the standalone objectos runtime serve these endpoints in production where service-cloud is not loaded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a49cfc2 commit 3c1eb12

1 file changed

Lines changed: 187 additions & 0 deletions

File tree

packages/runtime/src/cloud/auth-proxy-plugin.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,50 @@
2727
*/
2828

2929
import type { Plugin, PluginContext } from '@objectstack/core';
30+
import { createHmac, randomUUID } from 'node:crypto';
3031
import type { KernelManager } from './kernel-manager.js';
3132
import type { EnvironmentDriverRegistry } from './environment-registry.js';
3233

3334
const AUTH_PREFIX = '/api/v1/auth';
3435

36+
/**
37+
* HMAC-SHA256 + base64 + percent-encode the resulting `value.signature`
38+
* payload — replicates `serializeSignedCookie` from `better-call` so the
39+
* env's better-auth `getSignedCookie` validator accepts the cookie we
40+
* write here (without pulling better-call into runtime's deps).
41+
*/
42+
function signSessionCookieValue(rawToken: string, secret: string): string {
43+
const signature = createHmac('sha256', secret).update(rawToken).digest('base64');
44+
return encodeURIComponent(`${rawToken}.${signature}`);
45+
}
46+
47+
/**
48+
* Serialize a Set-Cookie header value matching better-auth's session-cookie
49+
* attributes. Attributes come from `authCookies.sessionToken.attributes`.
50+
*/
51+
function buildSetCookieHeader(
52+
name: string,
53+
encodedValue: string,
54+
attrs: Record<string, any> | undefined,
55+
maxAgeSec: number,
56+
): string {
57+
const parts: string[] = [`${name}=${encodedValue}`];
58+
const a = attrs ?? {};
59+
if (a.path) parts.push(`Path=${a.path}`); else parts.push('Path=/');
60+
if (Number.isFinite(maxAgeSec) && maxAgeSec > 0) parts.push(`Max-Age=${Math.floor(maxAgeSec)}`);
61+
if (a.domain) parts.push(`Domain=${a.domain}`);
62+
if (a.sameSite) {
63+
const ss = String(a.sameSite);
64+
parts.push(`SameSite=${ss.charAt(0).toUpperCase() + ss.slice(1)}`);
65+
} else {
66+
parts.push('SameSite=Lax');
67+
}
68+
if (a.secure) parts.push('Secure');
69+
if (a.httpOnly !== false) parts.push('HttpOnly');
70+
if (a.partitioned) parts.push('Partitioned');
71+
return parts.join('; ');
72+
}
73+
3574
// AuthCapableManager interface removed — unused (pickHandler uses duck typing on `any`).
3675

3776
function pickHandler(svc: any): ((req: Request) => Promise<Response>) | undefined {
@@ -162,6 +201,154 @@ export class AuthProxyPlugin implements Plugin {
162201
}
163202
}
164203

204+
// ── sso-handoff-issue ─────────────────────────────
205+
// POST /api/v1/auth/sso-handoff-issue
206+
//
207+
// Called server-to-server by cloud's `sso_as_owner`
208+
// action. Cloud control plane (createCloudStack) has
209+
// no kernel-manager, so it cannot write into the env's
210+
// better-auth verification table itself — this endpoint
211+
// does it locally where the env kernel lives.
212+
//
213+
// Auth: `Authorization: Bearer <OS_CLOUD_API_KEY>` must
214+
// match `process.env.OS_CLOUD_API_KEY` on the env
215+
// runtime. The same secret is shared between cloud and
216+
// every env runtime (cloud uses it to verify env →
217+
// cloud calls; we reuse it for the reverse direction).
218+
//
219+
// Body: { email, name?, by?, envId? } — payload that
220+
// sso-exchange will JSON.parse from the verification
221+
// value. Returns { token, expiresAt, ttlSec }.
222+
if (c.req.method === 'POST' && subPath === 'sso-handoff-issue') {
223+
try {
224+
const expected = (process.env.OS_CLOUD_API_KEY ?? '').trim();
225+
if (!expected) {
226+
return c.json({ error: 'sso_handoff_disabled', reason: 'OS_CLOUD_API_KEY unset on env runtime' }, 503);
227+
}
228+
const authz = c.req.header('authorization') ?? '';
229+
const provided = authz.toLowerCase().startsWith('bearer ')
230+
? authz.slice(7).trim()
231+
: '';
232+
if (!provided || provided !== expected) {
233+
return c.json({ error: 'unauthorized' }, 401);
234+
}
235+
236+
if (typeof authSvc?.getAuthContext !== 'function') {
237+
return c.json({ error: 'auth_service_unavailable' }, 503);
238+
}
239+
const handoffAuthCtx: any = await authSvc.getAuthContext();
240+
const internal = handoffAuthCtx?.internalAdapter;
241+
if (!internal?.createVerificationValue) {
242+
return c.json({ error: 'verification_api_unavailable' }, 503);
243+
}
244+
245+
let body: any = {};
246+
try { body = await c.req.json(); } catch { body = {}; }
247+
const email = String(body?.email ?? '').toLowerCase().trim();
248+
if (!email) return c.json({ error: 'email_required' }, 400);
249+
const name = body?.name == null ? null : String(body.name);
250+
const by = body?.by == null ? 'service' : String(body.by);
251+
const envIdInBody = body?.envId == null ? null : String(body.envId);
252+
253+
const handoff = randomUUID().replace(/-/g, '')
254+
+ randomUUID().replace(/-/g, '');
255+
const ttlSec = 60;
256+
const expiresAt = new Date(Date.now() + ttlSec * 1000);
257+
await internal.createVerificationValue({
258+
identifier: `sso-handoff:${handoff}`,
259+
value: JSON.stringify({ email, name, by, envId: envIdInBody ?? environmentId }),
260+
expiresAt,
261+
});
262+
return c.json({
263+
token: handoff,
264+
expiresAt: expiresAt.toISOString(),
265+
ttlSec,
266+
});
267+
} catch (err: any) {
268+
ctx.logger?.error?.('[AuthProxyPlugin] sso-handoff-issue failed', err instanceof Error ? err : new Error(String(err)));
269+
return c.json({ error: 'sso_handoff_issue_failed', message: String(err?.message ?? err) }, 500);
270+
}
271+
}
272+
273+
// ── sso-exchange ───────────────────────────────────
274+
// GET /api/v1/auth/sso-exchange?token=<handoff>&next=/
275+
//
276+
// Consumes a single-use handoff token previously written
277+
// by the cloud `sso_as_owner` action (via the
278+
// sso-handoff-issue endpoint above), finds-or-creates
279+
// the user in the env by email, mints a fresh better-auth
280+
// session, sets the signed session cookie and 302s to
281+
// `next`. If the user has no credential account yet, we
282+
// redirect to /_console/system/profile?recovery_needed=true
283+
// so they can configure a disaster-recovery local password.
284+
if (c.req.method === 'GET' && subPath === 'sso-exchange') {
285+
try {
286+
const token = (url.searchParams.get('token') ?? '').trim();
287+
const nextRaw = url.searchParams.get('next') ?? '/';
288+
const next = nextRaw.startsWith('/') ? nextRaw : '/';
289+
if (!token) return c.text('missing token', 400);
290+
291+
if (typeof authSvc?.getAuthContext !== 'function') {
292+
return c.text('auth service unavailable', 503);
293+
}
294+
const authCtx: any = await authSvc.getAuthContext();
295+
const internal = authCtx?.internalAdapter;
296+
if (!internal?.consumeVerificationValue) {
297+
return c.text('verification API unavailable', 503);
298+
}
299+
300+
const consumed = await internal.consumeVerificationValue(`sso-handoff:${token}`);
301+
if (!consumed) return c.text('invalid or expired token', 401);
302+
const expiresAt = consumed?.expiresAt ? new Date(consumed.expiresAt).getTime() : 0;
303+
if (!expiresAt || expiresAt < Date.now()) return c.text('expired token', 401);
304+
305+
let payload: { email?: string; name?: string | null } = {};
306+
try { payload = JSON.parse(String(consumed.value)); } catch { payload = { email: String(consumed.value) }; }
307+
const email = String(payload.email ?? '').toLowerCase().trim();
308+
if (!email) return c.text('handoff missing email', 400);
309+
310+
const found = await internal.findUserByEmail(email, { includeAccounts: true });
311+
let userId: string | undefined = found?.user?.id;
312+
let hasCredentialAccount = (found?.accounts ?? []).some((a: any) => a.providerId === 'credential' && a.password);
313+
314+
if (!userId) {
315+
const created = await internal.createUser({
316+
email,
317+
name: payload.name ?? email,
318+
emailVerified: true,
319+
});
320+
userId = created?.id;
321+
hasCredentialAccount = false;
322+
}
323+
if (!userId) return c.text('failed to provision user', 500);
324+
325+
const session = await internal.createSession(userId, false);
326+
const rawToken: string | undefined = session?.token;
327+
const sessionExpiresAt = session?.expiresAt ? new Date(session.expiresAt) : new Date(Date.now() + 7 * 24 * 3600 * 1000);
328+
if (!rawToken) return c.text('failed to mint session', 500);
329+
330+
const secret: string = authCtx?.secret ?? '';
331+
if (!secret) return c.text('auth secret unavailable', 503);
332+
const cookieName: string = authCtx?.authCookies?.sessionToken?.name ?? 'better-auth.session_token';
333+
const cookieAttrs = authCtx?.authCookies?.sessionToken?.attributes ?? {};
334+
const encoded = signSessionCookieValue(rawToken, secret);
335+
const maxAgeSec = Math.max(60, Math.floor((sessionExpiresAt.getTime() - Date.now()) / 1000));
336+
const setCookie = buildSetCookieHeader(cookieName, encoded, cookieAttrs, maxAgeSec);
337+
338+
const finalNext = hasCredentialAccount
339+
? next
340+
: `/_console/system/profile?recovery_needed=true&next=${encodeURIComponent(next)}`;
341+
const headers = new Headers();
342+
headers.set('Set-Cookie', setCookie);
343+
headers.set('Location', finalNext);
344+
headers.set('Cache-Control', 'no-store');
345+
return new Response(null, { status: 302, headers });
346+
} catch (err: any) {
347+
ctx.logger?.error?.('[AuthProxyPlugin] sso-exchange failed', err instanceof Error ? err : new Error(String(err)));
348+
return c.text(`sso-exchange failed: ${err?.message ?? String(err)}`, 500);
349+
}
350+
}
351+
165352
const fn = await resolveAuthHandler(authSvc);
166353
if (!fn) {
167354
return c.json({ error: 'auth_service_unavailable', environmentId }, 503);

0 commit comments

Comments
 (0)