Skip to content

Commit 8aa66f4

Browse files
hotlongCopilot
andcommitted
feat(cloud): per-project independent auth on cloud objectos
Each project owns its own users in its own Turso DB: - AuthPlugin moves from host kernel to per-project kernel via ArtifactKernelFactory (HKDF-derived per-project secret) - New AuthProxyPlugin mounts /api/v1/auth/* on the host Hono app and forwards Web Request to the per-project AuthManager.handleRequest - Per-project ObjectQLPlugin has skipSchemaSync:false so sys_user/ sys_session tables provision on first cold-start - Removed dispatcher /auth/* wildcard (was hijacking before AuthProxy via mockAuthFallback) - Removed OS_COOKIE_DOMAIN so cookies stay scoped per-project hostname - Strengthened CLI guard to skip host AuthPlugin in runtime mode Verified: sign-up, sign-in, get-session, cookie isolation across crm.objectos.app and other project subdomains. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f48a00b commit 8aa66f4

9 files changed

Lines changed: 299 additions & 14 deletions

File tree

apps/objectos/cloudflare/worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const FORWARDED_ENV_KEYS: readonly (keyof Env)[] = [
9595
// auth
9696
'AUTH_SECRET', 'OS_AUTH_SECRET',
9797
'AUTH_BASE_URL', 'OS_BASE_URL',
98-
'OS_TRUSTED_ORIGINS', 'OS_COOKIE_DOMAIN', 'OS_ROOT_DOMAIN',
98+
'OS_TRUSTED_ORIGINS', 'OS_ROOT_DOMAIN',
9999
'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET',
100100
'GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET',
101101
// cloud client

apps/objectos/wrangler.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ compatibility_flags = ["nodejs_compat"]
4444
# rebuild + re-push to ship a new version, then run `wrangler deploy`.
4545
[[containers]]
4646
class_name = "ObjectOSContainer"
47-
image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectos:59ed1a93-r2"
47+
image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectos:per-project-auth-14"
4848
max_instances = 5
4949
instance_type = "standard-1"
5050

packages/cli/src/commands/serve.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,22 @@ export default class Serve extends Command {
591591
?? process.env.OS_AUTH_SECRET
592592
?? (isDev ? 'dev-only-insecure-secret-change-me-in-production' : undefined);
593593

594-
if (!secret) {
594+
// Guard: in cloud-connected runtime mode (e.g. objectos worker)
595+
// the host kernel is a pure routing shell. Auth is owned by each
596+
// per-project kernel (`ArtifactKernelFactory` injects an
597+
// `AuthPlugin` per project against the project's own DB so users
598+
// persist and stay isolated per subdomain). Injecting a host-level
599+
// AuthPlugin here would compete with the per-project one — its
600+
// shared OS_AUTH_SECRET would erroneously validate cookies across
601+
// unrelated projects. Refuse to inject in runtime mode.
602+
const cloudUrl = process.env.OS_CLOUD_URL?.trim();
603+
const isRuntimeMode = !!cloudUrl && cloudUrl.toLowerCase() !== 'local' && cloudUrl.toLowerCase() !== 'off';
604+
if (isRuntimeMode) {
605+
console.warn(chalk.yellow(
606+
' ⚠ AuthPlugin skipped on host kernel — runtime mode (OS_CLOUD_URL set).\n' +
607+
' Auth is owned per-project by ArtifactKernelFactory (see service-cloud).'
608+
));
609+
} else if (!secret) {
595610
console.warn(chalk.yellow(' ⚠ AuthPlugin skipped — set AUTH_SECRET to enable authentication in production'));
596611
} else {
597612
const baseUrl = process.env.AUTH_BASE_URL

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
3535
* @default '/api/v1/auth'
3636
*/
3737
basePath?: string;
38+
39+
/**
40+
* Override the datasource that owns the identity tables (sys_user,
41+
* sys_session, …) when AuthPlugin's manifest is registered.
42+
*
43+
* Defaults to `'cloud'` (control-plane DB) so the historical
44+
* single-tenant control-plane behaviour is preserved. Per-project
45+
* kernels in objectos pass `'default'` so identity tables live in the
46+
* project's own database — each project owns its own users.
47+
*/
48+
manifestDatasource?: string;
3849
}
3950

4051
/**
@@ -107,6 +118,9 @@ export class AuthPlugin implements Plugin {
107118

108119
ctx.getService<{ register(m: any): void }>('manifest').register({
109120
...authPluginManifestHeader,
121+
...(this.options.manifestDatasource
122+
? { defaultDatasource: this.options.manifestDatasource }
123+
: {}),
110124
objects: authIdentityObjects,
111125
// The platform Setup App is a static metadata artifact (lives in
112126
// @objectstack/platform-objects/apps). plugin-auth is the natural

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,16 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
230230
});
231231

232232
// ── Auth ────────────────────────────────────────────────────
233+
// NOTE: /auth/* wildcard is mounted by AuthProxyPlugin (cloud)
234+
// or AuthPlugin (single-tenant) directly on the raw Hono app —
235+
// those handlers can return native Web `Response` objects which
236+
// is what better-auth produces. The dispatcher cannot represent
237+
// a streaming Response cleanly through `IHttpServer.send`, so
238+
// we deliberately do NOT register a dispatcher wildcard here.
239+
//
240+
// Legacy explicit /auth/login retained for self-hosted clients
241+
// that still POST there; superseded by the wildcard above for
242+
// the better-auth surface (sign-up/email, sign-in/email, …).
233243
server.post(`${prefix}/auth/login`, async (req: any, res: any) => {
234244
try {
235245
const result = await dispatcher.handleAuth('login', 'POST', req.body, { request: req });

packages/runtime/src/http-dispatcher.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,14 @@ export class HttpDispatcher {
294294
* so project-scoped meta routes can resolve their project).
295295
*/
296296
private async resolveEnvironmentContext(context: HttpProtocolContext, path: string): Promise<void> {
297-
// Skip environment resolution for control-plane routes.
298-
// NOTE: /meta is intentionally not in this list anymore — a scoped
297+
// Skip environment resolution for control-plane routes only.
298+
// NOTE: /meta is intentionally not in this list — a scoped
299299
// /projects/:id/meta path still needs the project resolved so the
300300
// protocol can scope its answer.
301-
const skipPaths = ['/auth', '/cloud', '/health', '/discovery'];
301+
// NOTE: /auth was removed — per-project AuthPlugin needs the
302+
// hostname-resolved projectId so the dispatcher kernel-swap routes
303+
// to the project's auth manager (not the host's).
304+
const skipPaths = ['/cloud', '/health', '/discovery'];
302305
if (skipPaths.some(p => path.startsWith(p))) {
303306
return;
304307
}

packages/services/service-cloud/src/artifact-kernel-factory.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@
1313
* plane: each project kernel only knows about its own data driver.
1414
*
1515
* The kernel is bootstrapped with:
16-
* • DriverPlugin(driver) — project-scoped data driver
16+
* • DriverPlugin(driver) — project-scoped data driver, also aliased
17+
* as the `'cloud'` datasource so AuthPlugin's
18+
* identity manifest resolves locally.
1719
* • ObjectQLPlugin
1820
* • MetadataPlugin (no system-object registration)
21+
* • AuthPlugin — per-project, derives an HKDF secret from
22+
* `OS_AUTH_SECRET` + projectId. Each project owns its
23+
* own `sys_user/sys_session/...` tables in its own
24+
* Turso DB. Cookies are scoped to the project's
25+
* hostname (no `.<root>`-wide cross-project leak).
1926
* • AppPlugin(artifact.metadata) — compiled developer code
2027
*/
2128

29+
import { createHmac } from 'node:crypto';
2230
import { ObjectKernel } from '@objectstack/core';
2331
import type * as Contracts from '@objectstack/spec/contracts';
2432
import { DriverPlugin, AppPlugin } from '@objectstack/runtime';
@@ -35,19 +43,44 @@ export interface ArtifactKernelFactoryConfig {
3543
logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void };
3644
/** Optional kernel constructor config. */
3745
kernelConfig?: ConstructorParameters<typeof ObjectKernel>[0];
46+
/**
47+
* Base secret used to derive per-project AuthPlugin secrets via
48+
* HKDF-style HMAC-SHA256(baseSecret, projectId). Falls back to
49+
* `process.env.OS_AUTH_SECRET` / `AUTH_SECRET` at construction time.
50+
*/
51+
authBaseSecret?: string;
52+
}
53+
54+
/**
55+
* Derive a deterministic per-project auth secret. HMAC-SHA256 of the
56+
* projectId keyed by the base secret yields a 64-char hex string that is:
57+
* - stable across container cold-starts (no DB lookup needed)
58+
* - independent per project (forging a token on project A does not
59+
* compromise project B)
60+
* - rotatable by changing the base secret (will invalidate all sessions)
61+
*/
62+
function deriveProjectAuthSecret(baseSecret: string, projectId: string): string {
63+
return createHmac('sha256', baseSecret).update(`project:${projectId}`).digest('hex');
3864
}
3965

4066
export class ArtifactKernelFactory implements ProjectKernelFactory {
4167
private readonly client: ArtifactApiClient;
4268
private readonly envRegistry: EnvironmentDriverRegistry;
4369
private readonly logger: NonNullable<ArtifactKernelFactoryConfig['logger']>;
4470
private readonly kernelConfig?: ArtifactKernelFactoryConfig['kernelConfig'];
71+
private readonly authBaseSecret: string;
4572

4673
constructor(config: ArtifactKernelFactoryConfig) {
4774
this.client = config.client;
4875
this.envRegistry = config.envRegistry;
4976
this.logger = config.logger ?? console;
5077
this.kernelConfig = config.kernelConfig;
78+
this.authBaseSecret = (
79+
config.authBaseSecret
80+
?? process.env.OS_AUTH_SECRET
81+
?? process.env.AUTH_SECRET
82+
?? ''
83+
).trim();
5184
}
5285

5386
async create(projectId: string): Promise<ObjectKernel> {
@@ -76,15 +109,62 @@ export class ArtifactKernelFactory implements ProjectKernelFactory {
76109

77110
const kernel = new ObjectKernel(this.kernelConfig);
78111

79-
await kernel.use(new DriverPlugin(driver));
80-
await kernel.use(new ObjectQLPlugin({ projectId: projectId }));
112+
// Register the project driver as both the unnamed default AND under
113+
// the `'cloud'` alias. AuthPlugin's manifest header historically
114+
// declares `defaultDatasource: 'cloud'`; aliasing here keeps that
115+
// path working without forcing every project's identity table
116+
// through a control-plane proxy.
117+
await kernel.use(new DriverPlugin(driver, { datasourceName: 'cloud' } as any));
118+
// Enable schema sync per-project so sys_user / sys_session / etc.
119+
// tables get created on the project's own DB. The host worker sets
120+
// `OS_SKIP_SCHEMA_SYNC=1` for the control-plane DB; that env var
121+
// must NOT bleed into project kernels because their auth tables
122+
// need provisioning. KernelManager caches kernels so this runs
123+
// at most once per cold-start per project.
124+
await kernel.use(new ObjectQLPlugin({ projectId: projectId, skipSchemaSync: false }));
81125
await kernel.use(new MetadataPlugin({
82126
watch: false,
83127
projectId: projectId,
84128
organizationId: project.organization_id,
85129
registerSystemObjects: false,
86130
}));
87131

132+
// Per-project AuthPlugin — only when an OS_AUTH_SECRET base is
133+
// configured. Without it we cannot derive a secret deterministically
134+
// and refuse to start auth (better silent-fail than insecure default).
135+
if (this.authBaseSecret) {
136+
try {
137+
const { AuthPlugin } = await import('@objectstack/plugin-auth');
138+
const projectSecret = deriveProjectAuthSecret(this.authBaseSecret, projectId);
139+
const baseUrl = project.hostname
140+
? (project.hostname.startsWith('http') ? project.hostname : `https://${project.hostname}`)
141+
: undefined;
142+
await kernel.use(new AuthPlugin({
143+
secret: projectSecret,
144+
baseUrl,
145+
// Project kernel has no http-server (host owns it). The
146+
// dispatcher's handleAuth path resolves `auth` via
147+
// getService and invokes the handler directly — route
148+
// registration is unnecessary and would warn.
149+
registerRoutes: false,
150+
// Identity tables live in the project's own DB — keep
151+
// sys_user/sys_session local to this kernel.
152+
manifestDatasource: 'default',
153+
// Cookie scope: default to the project's own host. We
154+
// intentionally do NOT pass crossSubDomainCookies here
155+
// so cookies stay isolated per project subdomain.
156+
trustedOrigins: baseUrl ? [baseUrl] : undefined,
157+
} as any));
158+
} catch (err: any) {
159+
this.logger.warn?.('[ArtifactKernelFactory] AuthPlugin not registered', {
160+
projectId,
161+
error: err?.message,
162+
});
163+
}
164+
} else {
165+
this.logger.warn?.('[ArtifactKernelFactory] OS_AUTH_SECRET not set — per-project AuthPlugin skipped (auth endpoints will return 404)', { projectId });
166+
}
167+
88168
const projectName = project.hostname ?? projectId;
89169
const bundle = artifact.metadata as any;
90170
const sys = bundle?.manifest ?? bundle;
@@ -104,6 +184,7 @@ export class ArtifactKernelFactory implements ProjectKernelFactory {
104184
projectId,
105185
commitId: artifact.commitId,
106186
checksum: artifact.checksum,
187+
authEnabled: Boolean(this.authBaseSecret),
107188
});
108189

109190
return kernel;
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* AuthProxyPlugin
5+
*
6+
* Mounts a single `/api/v1/auth/*` wildcard route on the host's Hono server
7+
* that forwards every request to the per-project `AuthManager` registered
8+
* by `ArtifactKernelFactory`.
9+
*
10+
* Why a dedicated plugin: AuthPlugin (better-auth) registers its routes by
11+
* grabbing the host's `http-server` service from its own `PluginContext`.
12+
* In objectos runtime mode AuthPlugin lives on a per-project kernel — it
13+
* has no access to the host's HTTP server, so its `kernel:ready` route
14+
* registration is a no-op. The dispatcher plugin's wildcard registration
15+
* via `IHttpServer.post('/auth/*', …)` proved unreliable in practice,
16+
* so this plugin uses Hono's raw app directly (same path AuthPlugin took
17+
* historically) which is rock solid.
18+
*
19+
* Routing:
20+
* 1. Resolve the project from the request hostname via `env-registry`.
21+
* 2. Acquire the project's kernel via `kernel-manager`.
22+
* 3. Look up the `auth` service on that kernel — this is the better-auth
23+
* handler injected by `ArtifactKernelFactory`.
24+
* 4. Build a Web `Request` using the project's canonical baseUrl and
25+
* hand it to the better-auth handler.
26+
* 5. Stream the response back through Hono.
27+
*/
28+
29+
import type { Plugin, PluginContext } from '@objectstack/core';
30+
import type { KernelManager } from './kernel-manager.js';
31+
import type { EnvironmentDriverRegistry } from './environment-registry.js';
32+
33+
const AUTH_PREFIX = '/api/v1/auth';
34+
35+
interface AuthCapableManager {
36+
handler?: (req: Request) => Promise<Response>;
37+
getApi?: () => Promise<{ handler: (req: Request) => Promise<Response> }>;
38+
api?: { handler: (req: Request) => Promise<Response> };
39+
}
40+
41+
function pickHandler(svc: any): ((req: Request) => Promise<Response>) | undefined {
42+
if (!svc) return undefined;
43+
// AuthManager exposes handleRequest(req) — preferred entry point.
44+
if (typeof svc.handleRequest === 'function') return svc.handleRequest.bind(svc);
45+
if (typeof svc.handler === 'function') return svc.handler.bind(svc);
46+
if (svc.api && typeof svc.api.handler === 'function') return svc.api.handler.bind(svc.api);
47+
// AuthManager keeps the better-auth instance under `auth`.
48+
if (svc.auth && typeof svc.auth.handler === 'function') return svc.auth.handler.bind(svc.auth);
49+
return undefined;
50+
}
51+
52+
async function resolveAuthHandler(svc: any): Promise<((req: Request) => Promise<Response>) | undefined> {
53+
const direct = pickHandler(svc);
54+
if (direct) return direct;
55+
if (typeof svc?.getApi === 'function') {
56+
try {
57+
const api = await svc.getApi();
58+
return pickHandler(api) ?? pickHandler({ api });
59+
} catch {
60+
return undefined;
61+
}
62+
}
63+
return undefined;
64+
}
65+
66+
export class AuthProxyPlugin implements Plugin {
67+
readonly name = 'com.objectstack.runtime.auth-proxy';
68+
readonly version = '1.0.0';
69+
70+
init = async (_ctx: PluginContext): Promise<void> => {
71+
// No services registered — pure HTTP wiring during start().
72+
};
73+
74+
start = async (ctx: PluginContext): Promise<void> => {
75+
// Mount routes on kernel:ready so HonoServerPlugin has finished
76+
// registering the http-server service. Doing this in start() can
77+
// race with HonoServerPlugin.init/start ordering.
78+
ctx.hook('kernel:ready', async () => {
79+
let httpServer: any;
80+
try {
81+
httpServer = ctx.getService('http-server');
82+
} catch {
83+
ctx.logger?.warn?.('[AuthProxyPlugin] http-server not available — auth routes not mounted');
84+
return;
85+
}
86+
if (!httpServer || typeof httpServer.getRawApp !== 'function') {
87+
ctx.logger?.warn?.('[AuthProxyPlugin] http-server missing getRawApp() — auth routes not mounted');
88+
return;
89+
}
90+
91+
const rawApp = httpServer.getRawApp();
92+
const kernelManager = ctx.getService<KernelManager>('kernel-manager');
93+
const envRegistry = ctx.getService<EnvironmentDriverRegistry>('env-registry');
94+
95+
const handler = async (c: any) => {
96+
try {
97+
const url = new URL(c.req.url);
98+
const host = url.hostname;
99+
let projectId: string | undefined;
100+
try {
101+
const env = await envRegistry.resolveByHostname(host);
102+
projectId = env?.projectId;
103+
} catch {
104+
// ignore
105+
}
106+
if (!projectId) {
107+
return c.json({ error: 'project_not_found', host }, 404);
108+
}
109+
110+
const projectKernel = await kernelManager.getOrCreate(projectId);
111+
let authSvc: any;
112+
try {
113+
authSvc = await (projectKernel as any).getServiceAsync?.('auth');
114+
} catch { authSvc = undefined; }
115+
if (!authSvc) {
116+
try { authSvc = (projectKernel as any).getService?.('auth'); } catch { /* ignore */ }
117+
}
118+
119+
const fn = await resolveAuthHandler(authSvc);
120+
if (!fn) {
121+
return c.json({ error: 'auth_service_unavailable', projectId }, 503);
122+
}
123+
124+
// Forward the original Web Request directly — better-auth
125+
// accepts a standard `Request` and returns a `Response`.
126+
return await fn(c.req.raw);
127+
} catch (err: any) {
128+
ctx.logger?.error?.('[AuthProxyPlugin] auth dispatch failed', {
129+
error: err?.message,
130+
stack: err?.stack,
131+
});
132+
return c.json({
133+
error: 'auth_dispatch_failed',
134+
message: err?.message ?? String(err),
135+
}, 500);
136+
}
137+
};
138+
139+
// Mount on every method via Hono's `all`. AuthPlugin previously
140+
// registered with `rawApp.all('/api/v1/auth/*', handler)` — same
141+
// shape here.
142+
if (typeof rawApp.all === 'function') {
143+
rawApp.all(`${AUTH_PREFIX}/*`, handler);
144+
} else {
145+
for (const m of ['get', 'post', 'put', 'delete', 'patch', 'options'] as const) {
146+
try { rawApp[m]?.(`${AUTH_PREFIX}/*`, handler); } catch { /* best effort */ }
147+
}
148+
}
149+
ctx.logger?.info?.(`[AuthProxyPlugin] auth proxy mounted at ${AUTH_PREFIX}/*`);
150+
});
151+
};
152+
}

0 commit comments

Comments
 (0)