Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/server/src/gateway/auth/api-auth-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ import { getRevokedTokenStore } from "./revoked-token-store.js";

export const TOKEN_EXPIRATION_MS = 24 * 60 * 60 * 1000;

/**
* Caller identity surfaced to handlers via `c.get("authContext")` after a
* successful auth check. `organizationId` is the token-bound or personal-org
* id when the auth path can resolve one (worker token payload, or
* `/oauth/userinfo` org slug); otherwise undefined. `createAgent` uses it to
* stamp the worker token for ephemeral agents so the cross-pod conversation
* lock can be acquired (#1068).
*/
export interface ApiAuthContext {
userId?: string;
organizationId?: string;
}

declare module "hono" {
interface ContextVariableMap {
authContext?: ApiAuthContext;
}
}

/**
* Creates a Hono middleware that enforces the standard auth check:
* 1. Settings session cookie 2. Worker token (local) 3. External OAuth
Expand All @@ -27,6 +46,7 @@ export function createApiAuthMiddleware(opts: {
if (opts.allowSettingsSession) {
const session = await verifySettingsSession(c);
if (session) {
c.set("authContext", { userId: session.userId } satisfies ApiAuthContext);
return next();
}
}
Expand All @@ -46,6 +66,10 @@ export function createApiAuthMiddleware(opts: {
if (workerData.jti && (await revokedTokens.isRevoked(workerData.jti))) {
return c.json({ success: false, error: "Unauthorized" }, 401);
}
c.set("authContext", {
userId: workerData.userId,
organizationId: workerData.organizationId,
} satisfies ApiAuthContext);
return next();
}
}
Expand All @@ -55,7 +79,13 @@ export function createApiAuthMiddleware(opts: {
if (opts.externalAuthClient) {
try {
const userInfo = await opts.externalAuthClient.fetchUserInfo(token);
if (userInfo?.sub) return next();
if (userInfo?.sub) {
c.set("authContext", {
userId: userInfo.sub,
organizationId: userInfo.organizationId,
} satisfies ApiAuthContext);
return next();
}
} catch {
// Token not valid for external auth, continue to next method
}
Expand Down
35 changes: 32 additions & 3 deletions packages/server/src/gateway/auth/external/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,28 @@ interface WellKnownMetadata {
grant_types_supported?: string[];
}

interface UserInfoResponse {
export interface UserInfoResponse {
sub: string;
email: string;
name?: string;
/**
* Token-bound org id, or — when the token has no org binding (e.g. a
* device-flow PAT) — the user's personal-org id. Resolved by mapping
* `organization_slug` to its entry in `organizations[]` (both already
* returned by `/oauth/userinfo`). Surfaced so the gateway middleware can
* attach an auth-context org to handlers like `createAgent`, which
* otherwise has no way to find the org for an ephemeral agent and
* downstream cross-pod conversation-lock acquisition fails (#1068).
*/
organizationId?: string;
}

interface UserInfoApiResponse {
sub: string;
email: string;
name?: string;
organization_slug?: string | null;
organizations?: { id: string; slug: string; name: string }[];
}

interface DynamicClientCredentials {
Expand Down Expand Up @@ -167,12 +185,23 @@ export class ExternalAuthClient {
);
}

const data = (await response.json()) as UserInfoResponse;
const data = (await response.json()) as UserInfoApiResponse;
const orgId =
data.organization_slug && data.organizations
? (data.organizations.find((o) => o.slug === data.organization_slug)
?.id ?? undefined)
: undefined;
logger.info("Fetched external auth user info", {
sub: data.sub,
email: data.email,
orgId,
});
return data;
return {
sub: data.sub,
email: data.email,
name: data.name,
organizationId: orgId,
};
}

async getCapabilities(): Promise<ExternalAuthCapabilities> {
Expand Down
54 changes: 42 additions & 12 deletions packages/server/src/gateway/routes/public/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ export interface AgentApiConfig {
"getSettings" | "listAgents" | "getMetadata"
>;
userAgentsStore?: UserAgentsStore;
agentMetadataStore?: Pick<AgentMetadataStore, "getMetadata">;
agentMetadataStore?: Pick<AgentMetadataStore, "getMetadata" | "createAgent">;
platformRegistry?: PlatformRegistry;
approveToolCall?: (
requestId: string,
Expand Down Expand Up @@ -638,21 +638,51 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono {
if (denial) return denial;
}

// Stamp the worker token with the agent's owning org so the egress
// proxy's per-tenant gates (grant/deny, judge cache, judge policy)
// can scope decisions by org. Ephemeral agents have no preexisting
// metadata; their token mints without orgId and the proxy falls
// through to unscoped checks for that worker — flagged for a
// future fix that derives org from the auth session.
const tokenOrganizationId =
// Stamp the worker token with the owning org so the egress proxy's
// per-tenant gates (grant/deny, judge cache, judge policy) can scope
// decisions by org. Prefer the agent's own metadata; fall back to the
// caller's organizationId (already resolved by `createLobuAuthBridge`
// from the PAT or Better Auth session). Without the fallback,
// ephemeral agents under `lobu chat -c local` mint a tokenless org and
// the downstream cross-pod conversation-lock guard refuses to spawn
// the worker (#1068).
const callerOrgId =
(c.get("organizationId") as string | undefined) ??
c.get("authContext")?.organizationId;
const metadataOrgId =
!isEphemeral && ownershipMetadataStore
? (await ownershipMetadataStore.getMetadata(agentId))?.organizationId
: undefined;

// For ephemeral agents, auto-provision settings from system-key
// providers (env-var-based API keys). No more template-agent fallback —
// there are no template/sandbox agents anymore.
const tokenOrganizationId = metadataOrgId ?? callerOrgId;

// For ephemeral agents, create the `agents` row first so subsequent
// `saveSettings` (an UPDATE-only path) actually persists. Without this,
// the row never exists, the UPDATE matches 0 rows silently, and the
// worker's session-context resolves `installedProviders = []` → no
// provider → "No model configured". Followed by provisioning system-
// key providers (env-var-based API keys) so the worker has something
// to talk to. No more template-agent fallback — there are no
// template/sandbox agents anymore.
if (isEphemeral && agentSettingsStore) {
if (agentMetadataStore?.createAgent) {
try {
await agentMetadataStore.createAgent(
agentId,
agentId,
"api",
agentId,
);
} catch (err) {
// saveMetadata is INSERT ... ON CONFLICT DO UPDATE under the hood,
// so a re-create of the same id within the same org is benign.
// Genuine errors (FK / org mismatch) bubble below and surface as
// failed-create.
logger.debug(
`Ephemeral agent ${agentId}: createAgent threw (likely re-create): ${err}`,
);
}
}

const providerModules = getModelProviderModules();
const systemProviders: InstalledProvider[] = providerModules
.filter((m) => m.hasSystemKey())
Expand Down
12 changes: 11 additions & 1 deletion packages/server/src/lobu/stores/postgres-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export function createPostgresAgentConfigStore(): AgentConfigStore {
const sql = getDb();
const orgId = getOrgId();
const now = new Date();
await sql`
const result = await sql`
UPDATE agents SET
model = ${settings.model ?? null},
model_selection = ${sql.json(settings.modelSelection ?? {})},
Expand All @@ -237,6 +237,16 @@ export function createPostgresAgentConfigStore(): AgentConfigStore {
updated_at = ${now}
WHERE id = ${agentId} AND organization_id = ${orgId}
`;
// UPDATE-only by design (agents row identity belongs to saveMetadata).
// Fail loud when no row matches so a save can't silently no-op — the
// ephemeral-chat path hit exactly this footgun (#1068 follow-up: agent
// row never existed → 0 rows updated → empty installedProviders → "No
// model configured"). Callers must call saveMetadata first.
if (result.count === 0) {
throw new Error(
`saveSettings: no agents row matches id=${agentId} org=${orgId}; call saveMetadata first`
);
}
},
async updateSettings(agentId, updates) {
const existing = await store.getSettings(agentId);
Expand Down
Loading