From 0fcb0f32198030cd34e63a8a4f450bbc44511eb3 Mon Sep 17 00:00:00 2001 From: thesujai Date: Thu, 3 Sep 2026 09:38:51 +0530 Subject: [PATCH 1/9] feat: Introduce a unified RequestContext for standalone, OIDC, and TrueFoundry authentication --- .changeset/unified-request-context-auth.md | 5 + charts/trueforge/README.md | 1 + charts/trueforge/templates/_helpers.tpl | 1 + charts/trueforge/values.yaml | 1 + docs/authentication/overview.mdx | 5 + packages/trueforge/scripts/write-openapi.ts | 2 + packages/trueforge/src/apis/agents.ts | 33 +++-- packages/trueforge/src/apis/auth.ts | 30 ++-- packages/trueforge/src/apis/capabilities.ts | 9 +- packages/trueforge/src/apis/mcpServers.ts | 49 ++++--- packages/trueforge/src/apis/modelProviders.ts | 26 +++- packages/trueforge/src/apis/models.ts | 6 +- .../trueforge/src/apis/sandboxProviders.ts | 18 ++- packages/trueforge/src/apis/schedules.ts | 99 ++++++++----- packages/trueforge/src/apis/sessionMetrics.ts | 17 ++- packages/trueforge/src/apis/sessions.ts | 110 ++++++++------ packages/trueforge/src/apis/settings.ts | 9 +- packages/trueforge/src/apis/skills.ts | 22 ++- packages/trueforge/src/apis/turns.ts | 116 +++++++++++---- packages/trueforge/src/app.ts | 82 ++++++----- packages/trueforge/src/auth/authenticator.ts | 33 +++++ packages/trueforge/src/auth/claims.ts | 47 ++++-- .../trueforge/src/auth/createAuthenticator.ts | 30 ++++ packages/trueforge/src/auth/identity.ts | 62 ++++---- packages/trueforge/src/auth/middleware.ts | 119 ++------------- .../trueforge/src/auth/oidcAuthenticator.ts | 44 ++++++ .../src/auth/standaloneAuthenticator.ts | 8 ++ packages/trueforge/src/auth/token.ts | 50 +++++++ .../src/auth/trueFoundryAuthenticator.ts | 60 ++++++++ packages/trueforge/src/config.ts | 9 ++ packages/trueforge/src/db/postgres/types.ts | 6 +- packages/trueforge/src/db/sqlite/types.ts | 6 +- packages/trueforge/src/main.ts | 36 ++++- packages/trueforge/src/mcp/auth/types.ts | 2 +- packages/trueforge/src/routes/authRoutes.ts | 10 +- packages/trueforge/src/schemas/auth.ts | 24 +++- .../TrueFoundryServiceFoundryServerClient.ts | 88 ++++++++++++ .../trueforge/tests/unit/apis/auth.test.ts | 84 +++++++---- .../tests/unit/apis/capabilities.test.ts | 17 ++- .../unit/apis/deletedSessionCrud.test.ts | 12 +- .../tests/unit/apis/mcpOAuth.test.ts | 15 +- .../tests/unit/apis/mcpServers.test.ts | 58 ++++---- .../tests/unit/apis/modelProviders.test.ts | 10 +- .../unit/apis/sandboxFileDownload.test.ts | 9 +- .../tests/unit/apis/sandboxProviders.test.ts | 12 +- .../tests/unit/apis/schedules.test.ts | 44 ++++-- .../tests/unit/apis/sessionHttp.test.ts | 31 ++-- .../unit/apis/turnHarnessErrorStatus.test.ts | 8 +- .../trueforge/tests/unit/apis/turns.test.ts | 20 +-- .../trueforge/tests/unit/auth/claims.test.ts | 106 ++++++++++---- .../tests/unit/auth/emailAllowlist.test.ts | 1 + .../tests/unit/auth/identity.test.ts | 84 +++++------ .../tests/unit/auth/middleware.test.ts | 135 ++++++++++++------ .../unit/runtime/getMcpConnection.test.ts | 39 +++-- .../unit/runtime/sessionResources.test.ts | 31 ++-- 55 files changed, 1336 insertions(+), 655 deletions(-) create mode 100644 .changeset/unified-request-context-auth.md create mode 100644 packages/trueforge/src/auth/authenticator.ts create mode 100644 packages/trueforge/src/auth/createAuthenticator.ts create mode 100644 packages/trueforge/src/auth/oidcAuthenticator.ts create mode 100644 packages/trueforge/src/auth/standaloneAuthenticator.ts create mode 100644 packages/trueforge/src/auth/token.ts create mode 100644 packages/trueforge/src/auth/trueFoundryAuthenticator.ts diff --git a/.changeset/unified-request-context-auth.md b/.changeset/unified-request-context-auth.md new file mode 100644 index 000000000..5a6c63e1d --- /dev/null +++ b/.changeset/unified-request-context-auth.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge': minor +--- + +Unify request-scoped RequestContext across standalone, OIDC, and TrueFoundry auth. `/auth/me` now returns `{ tenant_id, subject, is_admin }` (OpenAPI/SDK regen deferred to CI). diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index 029b30cbb..129d09abe 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -138,6 +138,7 @@ configs: key: client-secret # optional claim overrides (defaults shown): # userReferenceClaim: sub + # userDisplayNameClaim: name # userRoleClaim: groups # adminRoleValue: admin # scopes: "openid,profile,email,groups" diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index a5618d7f9..19c86df5f 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -215,6 +215,7 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- $env = append $env (dict "name" "OIDC_CLIENT_ID" "value" .Values.configs.oidc.clientId) -}} {{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "OIDC_CLIENT_SECRET" "field" "configs.oidc.clientSecret" "value" .Values.configs.oidc.clientSecret) | fromJson) -}} {{- $env = append $env (dict "name" "OIDC_USER_REFERENCE_CLAIM" "value" .Values.configs.oidc.userReferenceClaim) -}} +{{- $env = append $env (dict "name" "OIDC_USER_DISPLAY_NAME_CLAIM" "value" .Values.configs.oidc.userDisplayNameClaim) -}} {{- $env = append $env (dict "name" "OIDC_USER_ROLE_CLAIM" "value" .Values.configs.oidc.userRoleClaim) -}} {{- $env = append $env (dict "name" "OIDC_ADMIN_ROLE_VALUE" "value" .Values.configs.oidc.adminRoleValue) -}} {{- $env = append $env (dict "name" "OIDC_SCOPES" "value" .Values.configs.oidc.scopes) -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index 5b13fad53..c0acd56ff 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -106,6 +106,7 @@ configs: # name: trueforge-oidc # key: client-secret userReferenceClaim: sub + userDisplayNameClaim: name userRoleClaim: groups adminRoleValue: admin scopes: "openid,profile,email" diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index 284ba4f39..996a13400 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -73,6 +73,7 @@ The default. Do **not** set any `OIDC_*` variables, run TrueForge as usual ([Qui OIDC_CLIENT_ID=0oa... OIDC_CLIENT_SECRET=... OIDC_USER_REFERENCE_CLAIM=email + OIDC_USER_DISPLAY_NAME_CLAIM=name OIDC_USER_ROLE_CLAIM=groups OIDC_ADMIN_ROLE_VALUE=harness-admin OIDC_SCOPES=openid,profile,email,groups @@ -104,6 +105,7 @@ The default. Do **not** set any `OIDC_*` variables, run TrueForge as usual ([Qui OIDC_CLIENT_ID= OIDC_CLIENT_SECRET=... OIDC_USER_REFERENCE_CLAIM=email + OIDC_USER_DISPLAY_NAME_CLAIM=name OIDC_USER_ROLE_CLAIM=groups OIDC_ADMIN_ROLE_VALUE= OIDC_SCOPES=openid,profile,email @@ -124,6 +126,7 @@ The default. Do **not** set any `OIDC_*` variables, run TrueForge as usual ([Qui | `OIDC_CLIENT_ID` | Yes | OIDC client ID | | `OIDC_CLIENT_SECRET` | Yes | OIDC client secret | | `OIDC_USER_REFERENCE_CLAIM` | No (default `sub`) | Claim used as the user id for session ownership | + | `OIDC_USER_DISPLAY_NAME_CLAIM` | No (default `name`) | Claim used as the user's display name | | `OIDC_USER_ROLE_CLAIM` | No (default `groups`) | Claim inspected for admin membership | | `OIDC_ADMIN_ROLE_VALUE` | No (default `admin`) | Exact string that grants `admin` (case-sensitive) | | `OIDC_SCOPES` | No (default `openid,profile,email`) | Scopes for the authorize request | @@ -141,6 +144,7 @@ The default. Do **not** set any `OIDC_*` variables, run TrueForge as usual ([Qui OIDC_CLIENT_ID=0oa... OIDC_CLIENT_SECRET=... OIDC_USER_REFERENCE_CLAIM=email + OIDC_USER_DISPLAY_NAME_CLAIM=name OIDC_USER_ROLE_CLAIM=groups OIDC_ADMIN_ROLE_VALUE=harness-admin OIDC_SCOPES=openid,profile,email,groups @@ -167,6 +171,7 @@ The default. Do **not** set any `OIDC_*` variables, run TrueForge as usual ([Qui name: trueforge-oidc key: client-secret userReferenceClaim: email + userDisplayNameClaim: name userRoleClaim: groups adminRoleValue: harness-admin scopes: "openid,profile,email,groups" diff --git a/packages/trueforge/scripts/write-openapi.ts b/packages/trueforge/scripts/write-openapi.ts index 5d227f936..ee491b2ec 100644 --- a/packages/trueforge/scripts/write-openapi.ts +++ b/packages/trueforge/scripts/write-openapi.ts @@ -14,6 +14,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import winston from 'winston'; import { buildOpenApiDocument, createServerApp } from '../src/app'; +import { StandaloneAuthenticator } from '../src/auth/standaloneAuthenticator'; import { McpCatalog } from '../src/catalog/McpCatalog'; import { ModelCatalog } from '../src/catalog/ModelCatalog'; import { SandboxCatalog } from '../src/catalog/SandboxCatalog'; @@ -85,6 +86,7 @@ const app = createServerApp({ eventSubscriptions: new EventSubscriptionRegistry(undefined), logger: winston.createLogger({ silent: true }), oidcClient: undefined, + authenticator: new StandaloneAuthenticator(), }); // Runtime apps only advertise BearerAuth when OIDC is configured. The committed diff --git a/packages/trueforge/src/apis/agents.ts b/packages/trueforge/src/apis/agents.ts index f9cb16fc0..0bf65fe21 100644 --- a/packages/trueforge/src/apis/agents.ts +++ b/packages/trueforge/src/apis/agents.ts @@ -4,6 +4,7 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; import type { Context } from 'hono'; +import type { ResolveRequestContext } from '../auth/identity'; import { AgentNameConflictError, type AgentRecord, type IAgentStore } from '../db/agentStore'; import type { IMcpServerStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; @@ -21,7 +22,6 @@ import { import { validateAgentSpec } from '../runtime/sessionResources'; import { type Agent, type CreateAgentRequest } from '../schemas/agent'; import { buildAgentCodeSnippets } from './agentCodeSnippets'; -import { TENANT_ID } from './sessions'; export interface AgentsRouterDeps { agentStore: IAgentStore; @@ -30,6 +30,7 @@ export interface AgentsRouterDeps { skillStore: ISkillStore; sandboxProviderStore: ISandboxProviderStore; withTransaction: WithTransaction; + resolveRequestContext: ResolveRequestContext; } /** Wire view: identity columns plus nested manifest. */ @@ -46,15 +47,17 @@ async function validateManifest({ deps, modelProviderStore, mcpServerStore, + tenant_id, }: { spec: AgentSpec; deps: AgentsRouterDeps; modelProviderStore: IModelProviderStore; mcpServerStore: IMcpServerStore; + tenant_id: string; }): Promise { await validateAgentSpec({ spec, - tenant_id: TENANT_ID, + tenant_id, modelProviderStore, mcpServerStore, skillStore: deps.skillStore, @@ -65,21 +68,24 @@ async function validateManifest({ export function createAgentsRouter(deps: AgentsRouterDeps) { const listHandler: RouteHandler = async c => { - const records = await deps.agentStore.listAgents(TENANT_ID); + const requestContext = deps.resolveRequestContext(c); + const records = await deps.agentStore.listAgents(requestContext.tenant_id); return c.json({ data: records.map(toWireAgent) }, 200); }; const createHandler: RouteHandler = async c => { const body: CreateAgentRequest = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); const manifest = await validateManifest({ spec: body.manifest, deps, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), + tenant_id: requestContext.tenant_id, }); try { const record = await deps.agentStore.createAgent({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name: body.name, manifest, external_id: null, @@ -95,7 +101,11 @@ export function createAgentsRouter(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); - const record = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, id: agentId }); + const requestContext = deps.resolveRequestContext(c); + const record = await deps.agentStore.getAgent({ + tenant_id: requestContext.tenant_id, + id: agentId, + }); if (record === undefined) { return c.json({ error: { message: `Agent not found: ${agentId}` } }, 404); } @@ -104,7 +114,11 @@ export function createAgentsRouter(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); - const record = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, id: agentId }); + const requestContext = deps.resolveRequestContext(c); + const record = await deps.agentStore.getAgent({ + tenant_id: requestContext.tenant_id, + id: agentId, + }); if (record === undefined) { return c.json({ error: { message: `Agent not found: ${agentId}` } }, 404); } @@ -121,21 +135,24 @@ export function createAgentsRouter(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); - await deps.agentStore.deleteAgent({ tenant_id: TENANT_ID, id: agentId }); + const requestContext = deps.resolveRequestContext(c); + await deps.agentStore.deleteAgent({ tenant_id: requestContext.tenant_id, id: agentId }); return c.json({}, 200); }; const putHandler: RouteHandler = async c => { const { agent_id: agentId } = c.req.valid('param'); const body = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); const manifest = await validateManifest({ spec: body.manifest, deps, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), + tenant_id: requestContext.tenant_id, }); const record = await deps.agentStore.updateAgent({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, id: agentId, manifest, }); diff --git a/packages/trueforge/src/apis/auth.ts b/packages/trueforge/src/apis/auth.ts index 0a224aaa3..4ebf1eb55 100644 --- a/packages/trueforge/src/apis/auth.ts +++ b/packages/trueforge/src/apis/auth.ts @@ -1,12 +1,12 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; -import type { Context } from 'hono'; +import type { Context, MiddlewareHandler } from 'hono'; import type { Configuration } from 'openid-client'; import type { Logger } from 'winston'; import { clearAuthCookie, ID_TOKEN_COOKIE, OAUTH_STATE_COOKIE, readOAuthStateCookie } from '../auth/cookies'; -import { resolveUserContext } from '../auth/identity'; -import { authMiddleware, resolveAuthUser } from '../auth/middleware'; -import { buildLoginAuthorization, exchangeAuthorizationCode, getOidcVerify } from '../auth/oidc'; +import { resolveRequestContext } from '../auth/identity'; +import { resolveOidcRequestContext } from '../auth/middleware'; +import { buildLoginAuthorization, exchangeAuthorizationCode } from '../auth/oidc'; import { safeReturnTo } from '../auth/safeReturnTo'; import { authLoginRoute, authLogoutRoute, meRoute, oAuthCallbackRoute } from '../routes/authRoutes'; import type { GetMeResponse } from '../schemas/auth'; @@ -26,7 +26,7 @@ async function redirectIfAlreadyAuthenticated(params: { whenAuthenticated: string; }): Promise { try { - if (await resolveAuthUser(params.context)) { + if (await resolveOidcRequestContext(params.context)) { return params.context.redirect(params.whenAuthenticated, 302); } } catch { @@ -38,9 +38,13 @@ async function redirectIfAlreadyAuthenticated(params: { /** * Auth surfaces mounted at /api/v1/auth: login, callback, logout, me. - * Login, callback, and logout stay public; me requires {@link authMiddleware}. + * Login, callback, and logout stay public; me requires the injected {@link authMiddleware}. */ -export function createAuthRouter(params: { oidcClient: Configuration | undefined; logger: Logger }) { +export function createAuthRouter(params: { + oidcClient: Configuration | undefined; + logger: Logger; + authMiddleware: MiddlewareHandler; +}) { const router = new OpenAPIHono(); router.openapi(authLoginRoute, async c => { @@ -115,12 +119,14 @@ export function createAuthRouter(params: { oidcClient: Configuration | undefined }); const gated = new OpenAPIHono(); - gated.use('*', authMiddleware); + gated.use('*', params.authMiddleware); gated.openapi(meRoute, c => { - const user = resolveUserContext(c); - const body: GetMeResponse = getOidcVerify() - ? { type: 'oidc-connected', email: user.userRef, role: user.role } - : { type: 'default', email: user.userRef, role: user.role }; + const requestContext = resolveRequestContext(c); + const body: GetMeResponse = { + tenant_id: requestContext.tenant_id, + subject: requestContext.subject, + is_admin: requestContext.is_admin, + }; return c.json(body, 200); }); router.route('/', gated); diff --git a/packages/trueforge/src/apis/capabilities.ts b/packages/trueforge/src/apis/capabilities.ts index 1149a3024..cf018aba1 100644 --- a/packages/trueforge/src/apis/capabilities.ts +++ b/packages/trueforge/src/apis/capabilities.ts @@ -1,14 +1,13 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; import type { Logger } from 'winston'; -import { isAdmin, resolveUserContext } from '../auth/identity'; +import type { ResolveRequestContext } from '../auth/identity'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; import type { WithTransaction } from '../db/transaction'; import { getCapabilitiesRoute } from '../routes/capabilityRoutes'; import { isLocalSandboxFallbackEnabled } from '../sandbox/localRuntime'; import { checkSnapshotStatus } from '../sandbox/providerUtils'; import type { SandboxBuildStatus } from '../schemas/sandboxProvider'; -import { TENANT_ID } from './sessions'; /** * Why skills are unavailable, keyed off the sandbox build status. @@ -25,16 +24,18 @@ export function createCapabilitiesRouter(deps: { sandboxProviderStore: ISandboxProviderStore; withTransaction: WithTransaction; logger: Logger; + resolveRequestContext: ResolveRequestContext; }) { const router = new OpenAPIHono(); router.openapi(getCapabilitiesRoute, async c => { + const requestContext = deps.resolveRequestContext(c); // Sandbox is usable only when a provider is configured AND its image build reports ready. // Refresh the persisted status (and re-activate an idle snapshot); fail closed (disabled) if it throws. let status: SandboxBuildStatus | undefined; try { const refreshed = await checkSnapshotStatus({ store: deps.sandboxProviderStore, - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, logger: deps.logger, }); status = refreshed?.status; @@ -42,7 +43,7 @@ export function createCapabilitiesRouter(deps: { deps.logger.warn('Sandbox image status check failed; reporting sandbox disabled', extractErrorLogFields(error)); } const sandboxEnabled = status === 'ready' || (status === undefined && isLocalSandboxFallbackEnabled()); - const settingsEnabled = isAdmin(resolveUserContext(c)); + const settingsEnabled = requestContext.is_admin; return c.json( { data: { diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index 5427d88a2..805a51b92 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -2,7 +2,7 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import { extractErrorLogFields, isAuthRequired, McpConnectionError, RemoteMCP } from '@truefoundry/trueforge-core/core'; import type { Context } from 'hono'; import type { Logger } from 'winston'; -import type { ResolveUserContext } from '../auth/identity'; +import type { ResolveRequestContext } from '../auth/identity'; import { safeReturnTo } from '../auth/safeReturnTo'; import configuration from '../config'; import { @@ -35,14 +35,13 @@ import type { UpdateMcpServerRequest, } from '../schemas/mcpServer'; import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretValue } from '../utils/secretRedaction'; -import { TENANT_ID } from './sessions'; export interface McpServersRouterDeps { resolveMcpServerStore: (c: Context) => IMcpServerWithAuthStore; tokenStore: IOAuthTokenStore; withTransaction: WithTransaction; logger: Logger; - resolveUserContext: ResolveUserContext; + resolveRequestContext: ResolveRequestContext; } /** Omits keys whose value is `undefined` so wire objects satisfy JSONValue index signatures. */ @@ -119,9 +118,10 @@ async function toConfiguredMcpServer(params: { /** Admin/settings MCP CRUD (mounted at /api/v1/settings/mcp-servers). */ export function createSettingsMcpServersRouter(deps: McpServersRouterDeps) { const listHandler: RouteHandler = async c => { - const userRef = deps.resolveUserContext(c).userRef; + const requestContext = deps.resolveRequestContext(c); + const userRef = requestContext.subject.id; const records = await deps.resolveMcpServerStore(c).listServers({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, names: undefined, }); const statuses = await deps.resolveMcpServerStore(c).resolveAuthStatuses({ @@ -138,9 +138,10 @@ export function createSettingsMcpServersRouter(deps: McpServersRou const getHandler: RouteHandler = async c => { const { name } = c.req.valid('param'); - const userRef = deps.resolveUserContext(c).userRef; + const requestContext = deps.resolveRequestContext(c); + const userRef = requestContext.subject.id; const record = await deps.resolveMcpServerStore(c).getServer({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name, }); if (!record) { @@ -154,6 +155,7 @@ export function createSettingsMcpServersRouter(deps: McpServersRou const createHandler: RouteHandler = async c => { const body: CreateMcpServerRequest = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); const incomingManifest = body.manifest; // DCR finishes before the txn (remote I/O stays out of withTransaction on create). @@ -193,7 +195,7 @@ export function createSettingsMcpServersRouter(deps: McpServersRou const record = await deps.withTransaction(async transaction => { const saved = await deps.resolveMcpServerStore(c).createServer( { - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name: manifest.name, manifest, }, @@ -210,7 +212,7 @@ export function createSettingsMcpServersRouter(deps: McpServersRou data: await toConfiguredMcpServer({ store: deps.resolveMcpServerStore(c), record, - userRef: deps.resolveUserContext(c).userRef, + userRef: requestContext.subject.id, }), }, 201, @@ -224,7 +226,8 @@ export function createSettingsMcpServersRouter(deps: McpServersRou }; const putHandler: RouteHandler = async c => { - const userRef = deps.resolveUserContext(c).userRef; + const requestContext = deps.resolveRequestContext(c); + const userRef = requestContext.subject.id; const body: UpdateMcpServerRequest = c.req.valid('json'); const incomingManifest = body.manifest; @@ -237,7 +240,7 @@ export function createSettingsMcpServersRouter(deps: McpServersRou const record = await deps.withTransaction(async transaction => { const existing = await deps .resolveMcpServerStore(c) - .getServerForUpdate({ tenant_id: TENANT_ID, name: incomingManifest.name }, transaction); + .getServerForUpdate({ tenant_id: requestContext.tenant_id, name: incomingManifest.name }, transaction); const manifest = resolveMcpServerManifestForWrite({ incoming: incomingManifest, existing: existing?.manifest, @@ -265,7 +268,7 @@ export function createSettingsMcpServersRouter(deps: McpServersRou const saved = await deps.resolveMcpServerStore(c).upsertServer( { - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name: manifest.name, manifest, }, @@ -315,7 +318,8 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const authorizeHandler: RouteHandler = async c => { const { name } = c.req.valid('param'); const { return_to: returnTo } = c.req.valid('query'); - const userRef = deps.resolveUserContext(c).userRef; + const requestContext = deps.resolveRequestContext(c); + const userRef = requestContext.subject.id; if (returnTo && safeReturnTo(returnTo) !== returnTo) { return c.json({ error: { message: 'Invalid return_to: must be a same-origin relative path' } }, 400); @@ -323,7 +327,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< try { const authStatus: McpAuthStatus = await deps.resolveMcpServerStore(c).authorize({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name, userRef, ...(returnTo !== undefined ? { returnTo } : {}), @@ -353,10 +357,11 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const listToolsHandler: RouteHandler = async c => { const { name } = c.req.valid('param'); - const userRef = deps.resolveUserContext(c).userRef; + const requestContext = deps.resolveRequestContext(c); + const userRef = requestContext.subject.id; // Same url + header resolution as turn execution (store Bearer, DCR, or static headers). const connection = await getMcpConnection({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name, store: deps.resolveMcpServerStore(c), tokenStore: deps.tokenStore, @@ -397,17 +402,18 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const deleteAuthorizationHandler: RouteHandler = async c => { const { name } = c.req.valid('param'); - const userRef = deps.resolveUserContext(c).userRef; + const requestContext = deps.resolveRequestContext(c); + const userRef = requestContext.subject.id; try { const record = await deps.resolveMcpServerStore(c).getServer({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name, }); if (!record) { return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); } await deps.resolveMcpServerStore(c).deleteAuthorization({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name, userRef, }); @@ -427,9 +433,10 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const router = new OpenAPIHono(); router.openapi(listAvailableMcpServersRoute, async c => { - const userRef = deps.resolveUserContext(c).userRef; + const requestContext = deps.resolveRequestContext(c); + const userRef = requestContext.subject.id; const records = await deps.resolveMcpServerStore(c).listServers({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, names: undefined, }); const statuses = await deps.resolveMcpServerStore(c).resolveAuthStatuses({ diff --git a/packages/trueforge/src/apis/modelProviders.ts b/packages/trueforge/src/apis/modelProviders.ts index 8be433fba..6dcc0efa9 100644 --- a/packages/trueforge/src/apis/modelProviders.ts +++ b/packages/trueforge/src/apis/modelProviders.ts @@ -1,5 +1,6 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import type { Context } from 'hono'; +import type { ResolveRequestContext } from '../auth/identity'; import { ModelProviderNameConflictError, type IModelProviderStore, @@ -19,11 +20,11 @@ import { type UpdateModelProviderRequest, } from '../schemas/modelProvider'; import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretValue } from '../utils/secretRedaction'; -import { TENANT_ID } from './sessions'; export interface ModelProvidersRouterDeps { resolveModelProviderStore: (c: Context) => IModelProviderStore; withTransaction: WithTransaction; + resolveRequestContext: ResolveRequestContext; } function redactModelProvider(manifest: ModelProviderManifest): ModelProviderManifest { @@ -67,18 +68,26 @@ function toWireProvider(record: ModelProviderRecord): ConfiguredModelProvider { export function createModelProvidersRouter(deps: ModelProvidersRouterDeps) { const listHandler: RouteHandler = async c => { - const records = await deps.resolveModelProviderStore(c).listProviders({ tenant_id: TENANT_ID }); + const requestContext = deps.resolveRequestContext(c); + const records = await deps + .resolveModelProviderStore(c) + .listProviders({ tenant_id: requestContext.tenant_id }); return c.json({ data: records.map(toWireProvider) }, 200); }; const createHandler: RouteHandler = async c => { const body: CreateModelProviderRequest = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); const provider = body.manifest; const name = modelProviderName(provider); try { // Create has no prior row; redacted keep resolves to MissingStoredSecretError → 400. const manifest = resolveModelProviderManifestForWrite({ incoming: provider, existing: undefined }); - const record = await deps.resolveModelProviderStore(c).createProvider({ tenant_id: TENANT_ID, name, manifest }); + const record = await deps.resolveModelProviderStore(c).createProvider({ + tenant_id: requestContext.tenant_id, + name, + manifest, + }); return c.json({ data: toWireProvider(record) }, 201); } catch (error) { if (error instanceof MissingStoredSecretError) { @@ -94,18 +103,25 @@ export function createModelProvidersRouter(deps: ModelProvidersRou const putHandler: RouteHandler = async c => { const store = deps.resolveModelProviderStore(c); const body: UpdateModelProviderRequest = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); const provider = body.manifest; const name = modelProviderName(provider); try { // Lock → resolve secret from that snapshot → upsert, all in one txn so concurrent keep // cannot re-write a secret over a rotate that committed in between. const record = await deps.withTransaction(async transaction => { - const existing = await store.getProviderForUpdate({ tenant_id: TENANT_ID, name }, transaction); + const existing = await store.getProviderForUpdate( + { tenant_id: requestContext.tenant_id, name }, + transaction, + ); const manifest = resolveModelProviderManifestForWrite({ incoming: provider, existing: existing?.manifest, }); - return store.upsertProvider({ tenant_id: TENANT_ID, name, manifest }, transaction); + return store.upsertProvider( + { tenant_id: requestContext.tenant_id, name, manifest }, + transaction, + ); }); return c.json({ data: toWireProvider(record) }, 200); } catch (error) { diff --git a/packages/trueforge/src/apis/models.ts b/packages/trueforge/src/apis/models.ts index 67172b04e..847d37d59 100644 --- a/packages/trueforge/src/apis/models.ts +++ b/packages/trueforge/src/apis/models.ts @@ -1,19 +1,21 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import type { Context } from 'hono'; +import type { ResolveRequestContext } from '../auth/identity'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { WithTransaction } from '../db/transaction'; import { listAvailableModelsRoute } from '../routes/modelRoutes'; -import { TENANT_ID } from './sessions'; /** Chat slim list (mounted at /api/v1/models) — mirrors GET /api/v1/skills. */ export function createModelsRouter(deps: { resolveModelProviderStore: (c: Context) => IModelProviderStore; withTransaction: WithTransaction; + resolveRequestContext: ResolveRequestContext; }) { const router = new OpenAPIHono(); router.openapi(listAvailableModelsRoute, async c => { const store = deps.resolveModelProviderStore(c); - return c.json({ data: await store.listModels({ tenant_id: TENANT_ID }) }, 200); + const requestContext = deps.resolveRequestContext(c); + return c.json({ data: await store.listModels({ tenant_id: requestContext.tenant_id }) }, 200); }); return router; } diff --git a/packages/trueforge/src/apis/sandboxProviders.ts b/packages/trueforge/src/apis/sandboxProviders.ts index a4a77aad7..843810115 100644 --- a/packages/trueforge/src/apis/sandboxProviders.ts +++ b/packages/trueforge/src/apis/sandboxProviders.ts @@ -1,6 +1,7 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import { withTimeout } from '@truefoundry/trueforge-core/core'; import type { Logger } from 'winston'; +import type { ResolveRequestContext } from '../auth/identity'; import type { ISandboxProviderStore, SandboxProviderRecord } from '../db/sandboxProviderStore'; import type { WithTransaction } from '../db/transaction'; import { getSandboxProviderRoute, putSandboxProviderRoute } from '../routes/sandboxProviderRoutes'; @@ -13,7 +14,6 @@ import { } from '../sandbox/providerUtils'; import type { SandboxProviderManifest, UpdateSandboxProviderRequest } from '../schemas/sandboxProvider'; import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretValue } from '../utils/secretRedaction'; -import { TENANT_ID } from './sessions'; /** Cap the Daytona register round-trip so a slow/unreachable provider can't hold the request (or DB txn) open. */ const BUILD_REQUEST_TIMEOUT_MS = 3_000; @@ -22,6 +22,7 @@ export interface SandboxProvidersRouterDeps { sandboxProviderStore: ISandboxProviderStore; withTransaction: WithTransaction; logger: Logger; + resolveRequestContext: ResolveRequestContext; } function redactSandboxProvider(manifest: SandboxProviderManifest): SandboxProviderManifest { @@ -34,14 +35,15 @@ function redactSandboxProvider(manifest: SandboxProviderManifest): SandboxProvid /** Admin/settings sandbox provider surface (mounted at /api/v1/settings/sandbox-providers). */ export function createSandboxProvidersRouter(deps: SandboxProvidersRouterDeps) { const getHandler: RouteHandler = async c => { - const record = await deps.sandboxProviderStore.getSandboxProvider(TENANT_ID); + const requestContext = deps.resolveRequestContext(c); + const record = await deps.sandboxProviderStore.getSandboxProvider(requestContext.tenant_id); if (record === undefined) { return c.json({ error: { message: 'No sandbox provider configured' } }, 404); } // Refresh the persisted build status (and re-activate an idle snapshot) on every GET. const status = await checkSnapshotStatus({ store: deps.sandboxProviderStore, - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, logger: deps.logger, }); return c.json( @@ -58,6 +60,7 @@ export function createSandboxProvidersRouter(deps: SandboxProvider const putHandler: RouteHandler = async c => { const body: UpdateSandboxProviderRequest = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); const incoming = body.manifest; const resolveManifest = (existing: SandboxProviderRecord | undefined): SandboxProviderManifest => ({ ...incoming, @@ -71,13 +74,16 @@ export function createSandboxProvidersRouter(deps: SandboxProvider try { // NOTE: build (Daytona network I/O) runs inside the transaction for now; the design is being revisited. const { manifest, status } = await deps.withTransaction(async transaction => { - const locked = await deps.sandboxProviderStore.getSandboxProviderForUpdate(TENANT_ID, transaction); + const locked = await deps.sandboxProviderStore.getSandboxProviderForUpdate( + requestContext.tenant_id, + transaction, + ); const resolved = resolveManifest(locked); // Pass persisted build_metadata so a settings re-save does not start a new snapshot for a // bumped SANDBOX_IMAGE_URI (upgrades are unsupported — first configure has no metadata). const provider = toDaytonaSandboxProvider({ manifest: resolved, - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, logger: deps.logger, ...(locked ? { build_metadata: locked.build_metadata } : {}), }); @@ -85,7 +91,7 @@ export function createSandboxProvidersRouter(deps: SandboxProvider await withTimeout(provider.buildImage(), BUILD_REQUEST_TIMEOUT_MS, 'sandbox buildImage'), ); await deps.sandboxProviderStore.upsertSandboxProvider( - { tenant_id: TENANT_ID, manifest: resolved, ...built }, + { tenant_id: requestContext.tenant_id, manifest: resolved, ...built }, transaction, ); return { manifest: resolved, status: built }; diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index 68a3b4183..0a55cec42 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -3,8 +3,7 @@ */ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import { InvalidPageTokenError, type Sessions } from '@truefoundry/trueforge-core/agent-session'; -import type { Context } from 'hono'; -import type { UserContext } from '../auth/identity'; +import type { ResolveRequestContext, RequestContext } from '../auth/identity'; import { ScheduleAgentNotFoundError, startScheduleRun } from '../controller/scheduleDispatch'; import type { IAgentStore } from '../db/agentStore'; import { @@ -33,7 +32,6 @@ import { type ScheduleManifest, type ScheduleRun, } from '../schemas/schedule'; -import { TENANT_ID } from './sessions'; import { getTurnExecutionError, startTurnInProcess, type BeginTurnExecutionDeps } from './turns'; export interface SchedulesRouterDeps { @@ -42,7 +40,7 @@ export interface SchedulesRouterDeps { sessions: Sessions; turnDeps: BeginTurnExecutionDeps; withTransaction: WithTransaction; - resolveUserContext: (c: Context) => UserContext; + resolveRequestContext: ResolveRequestContext; } function toWireSchedule(record: ScheduleRecord): Schedule { @@ -106,27 +104,29 @@ const FORBIDDEN_SCHEDULE_ACCESS = 'Only the schedule creator can access this sch /** * A schedule is visible to its creator, and to any admin. * - * The role is read directly rather than via {@link isAdmin}, which reports admin - * whenever auth is disabled — irrelevant here, since the sole standalone identity - * already carries the admin role and owns everything it created. + * Standalone auth stamps `is_admin: true` on the sole identity, which already + * owns everything it created — so admin bypass is a no-op there. */ -function canAccessSchedule(user: UserContext, createdBy: string): boolean { - return user.role === 'admin' || user.userRef === createdBy; +function canAccessSchedule( + requestContext: Pick, + createdBy: string, +): boolean { + return requestContext.is_admin || requestContext.subject.id === createdBy; } export function createSchedulesRouter(deps: SchedulesRouterDeps) { const listHandler: RouteHandler = async c => { const { agent_names: agentNames, limit, page_token: pageToken } = c.req.valid('query'); - const user = deps.resolveUserContext(c); + const requestContext = deps.resolveRequestContext(c); // Admins see every schedule; a regular user is scoped to their own via the // store's `created_by` filter (never a client-supplied param). try { const { data, pagination } = await deps.scheduleStore.listSchedules({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, limit, page_token: pageToken, agent_names: agentNames, - created_by: user.role === 'admin' ? undefined : user.userRef, + created_by: requestContext.is_admin ? undefined : requestContext.subject.id, }); return c.json({ data: data.map(toWireSchedule), pagination }, 200); } catch (error) { @@ -139,26 +139,36 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps = async c => { const { schedule_id: scheduleId } = c.req.valid('param'); - const schedule = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId }); + const requestContext = deps.resolveRequestContext(c); + const schedule = await deps.scheduleStore.getSchedule({ + tenant_id: requestContext.tenant_id, + id: scheduleId, + }); if (schedule === undefined) { return c.json({ error: { message: `Schedule not found: ${scheduleId}` } }, 404); } - if (!canAccessSchedule(deps.resolveUserContext(c), schedule.created_by)) { + if (!canAccessSchedule(requestContext, schedule.created_by)) { return c.json({ error: { message: FORBIDDEN_SCHEDULE_ACCESS } }, 403); } - const records = await deps.scheduleStore.listRuns({ tenant_id: TENANT_ID, schedule_id: scheduleId }); + const records = await deps.scheduleStore.listRuns({ + tenant_id: requestContext.tenant_id, + schedule_id: scheduleId, + }); return c.json({ data: records.map(toWireScheduleRun) }, 200); }; const createScheduleRunHandler: RouteHandler = async c => { const { schedule_id: scheduleId } = c.req.valid('json'); - const user = deps.resolveUserContext(c); + const requestContext = deps.resolveRequestContext(c); - const schedule = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId }); + const schedule = await deps.scheduleStore.getSchedule({ + tenant_id: requestContext.tenant_id, + id: scheduleId, + }); if (schedule === undefined) { return c.json({ error: { message: `Schedule not found: ${scheduleId}` } }, 404); } - if (!canAccessSchedule(user, schedule.created_by)) { + if (!canAccessSchedule(requestContext, schedule.created_by)) { return c.json({ error: { message: FORBIDDEN_SCHEDULE_ACCESS } }, 403); } @@ -166,12 +176,12 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps(deps: SchedulesRouterDeps(deps: SchedulesRouterDeps = async c => { const body = c.req.valid('json'); - const user = deps.resolveUserContext(c); + const requestContext = deps.resolveRequestContext(c); validateManifest(body.manifest); - const agent = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, name: body.agent_name }); + const agent = await deps.agentStore.getAgent({ + tenant_id: requestContext.tenant_id, + name: body.agent_name, + }); if (agent === undefined) { return c.json({ error: { message: `Agent not found: ${body.agent_name}` } }, 400); } @@ -227,11 +243,11 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps { const { schedule } = await deps.scheduleStore.createScheduleAndRun( { - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, agent_name: agent.name, name: body.name, manifest: body.manifest, - created_by: user.userRef, + created_by: requestContext.subject.id, runFrom: new Date(), }, transaction, @@ -253,11 +269,15 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps = async c => { const { schedule_id: scheduleId } = c.req.valid('param'); - const record = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId }); + const requestContext = deps.resolveRequestContext(c); + const record = await deps.scheduleStore.getSchedule({ + tenant_id: requestContext.tenant_id, + id: scheduleId, + }); if (record === undefined) { return c.json({ error: { message: `Schedule not found: ${scheduleId}` } }, 404); } - if (!canAccessSchedule(deps.resolveUserContext(c), record.created_by)) { + if (!canAccessSchedule(requestContext, record.created_by)) { return c.json({ error: { message: FORBIDDEN_SCHEDULE_ACCESS } }, 403); } return c.json({ data: toWireSchedule(record) }, 200); @@ -276,14 +296,18 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps = async c => { const { schedule_id: scheduleId } = c.req.valid('param'); const body = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); validateManifest(body.manifest); - const existing = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId }); + const existing = await deps.scheduleStore.getSchedule({ + tenant_id: requestContext.tenant_id, + id: scheduleId, + }); if (existing === undefined) { return c.json({ error: { message: `Schedule not found: ${scheduleId}` } }, 404); } - if (!canAccessSchedule(deps.resolveUserContext(c), existing.created_by)) { + if (!canAccessSchedule(requestContext, existing.created_by)) { return c.json({ error: { message: FORBIDDEN_SCHEDULE_ACCESS } }, 403); } @@ -292,7 +316,7 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps { const result = await deps.scheduleStore.updateScheduleAndRun( { - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, id: scheduleId, name: body.name, manifest: body.manifest, @@ -320,14 +344,21 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps = async c => { const { schedule_id: scheduleId } = c.req.valid('param'); - const record = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId }); + const requestContext = deps.resolveRequestContext(c); + const record = await deps.scheduleStore.getSchedule({ + tenant_id: requestContext.tenant_id, + id: scheduleId, + }); if (record === undefined) { return c.json({}, 200); } - if (!canAccessSchedule(deps.resolveUserContext(c), record.created_by)) { + if (!canAccessSchedule(requestContext, record.created_by)) { return c.json({ error: { message: FORBIDDEN_SCHEDULE_ACCESS } }, 403); } - await deps.scheduleStore.deleteSchedule({ tenant_id: TENANT_ID, id: scheduleId }); + await deps.scheduleStore.deleteSchedule({ + tenant_id: requestContext.tenant_id, + id: scheduleId, + }); return c.json({}, 200); }; diff --git a/packages/trueforge/src/apis/sessionMetrics.ts b/packages/trueforge/src/apis/sessionMetrics.ts index 7e9b5d9b6..c3a4181ac 100644 --- a/packages/trueforge/src/apis/sessionMetrics.ts +++ b/packages/trueforge/src/apis/sessionMetrics.ts @@ -2,18 +2,17 @@ * Internal session metrics APIs (mounted at /api/internal/metrics). */ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; -import type { ResolveUserContext } from '../auth/identity'; +import type { ResolveRequestContext } from '../auth/identity'; import { buildSessionMetricsCharts, type ISessionMetricsStore } from '../db/sessionMetricsStore'; import { getSessionMetricsChartsDataRoute, getSessionMetricsChartsRoute, getSessionMetricsMetersRoute, } from '../routes/sessionMetricsRoutes'; -import { TENANT_ID } from './sessions'; export interface InternalMetricsRouterDeps { sessionMetricsStore: ISessionMetricsStore; - resolveUserContext: ResolveUserContext; + resolveRequestContext: ResolveRequestContext; } export function createInternalMetricsRouter(deps: InternalMetricsRouterDeps) { @@ -21,11 +20,11 @@ export function createInternalMetricsRouter(deps: InternalMetricsRouterDeps) { const getSessionMetricsMetersHandler: RouteHandler = async c => { const query = c.req.valid('query'); - const user = deps.resolveUserContext(c); + const requestContext = deps.resolveRequestContext(c); const metrics = await deps.sessionMetricsStore.getSessionMetricsMeters({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, agent_id: query.agent_id, - created_by: user.userRef, + created_by: requestContext.subject.id, start_timestamp: query.start_timestamp, end_timestamp: query.end_timestamp, }); @@ -38,11 +37,11 @@ export function createInternalMetricsRouter(deps: InternalMetricsRouterDeps) { const getSessionMetricsChartsDataHandler: RouteHandler = async c => { const query = c.req.valid('query'); - const user = deps.resolveUserContext(c); + const requestContext = deps.resolveRequestContext(c); const chartData = await deps.sessionMetricsStore.getSessionMetricsChartData({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, agent_id: query.agent_id, - created_by: user.userRef, + created_by: requestContext.subject.id, start_timestamp: query.start_timestamp, end_timestamp: query.end_timestamp, chart_name: query.chart_name, diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index daefc34c1..da459c83d 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -21,7 +21,7 @@ import type { Context } from 'hono'; import type { RedisClientType } from 'redis'; import type { Logger } from 'winston'; import { z } from 'zod'; -import type { ResolveUserContext } from '../auth/identity'; +import type { ResolveRequestContext } from '../auth/identity'; import configuration from '../config'; import type { IAgentStore } from '../db/agentStore'; import type { IMcpServerStore } from '../db/mcpServerStore'; @@ -44,9 +44,6 @@ import { validateAgentSpec } from '../runtime/sessionResources'; import { isSessionAgentNameRef, type Session } from '../schemas/session'; import { newId } from '../utils/id'; -/** The server is single-tenant; every record lives under one fixed tenant scope. */ -export const TENANT_ID = 'default'; - /** Request-reply path a replica serves to cancel a turn it owns. */ export const SESSIONS_CANCEL_PATH = 'sessions/cancel'; @@ -82,7 +79,7 @@ export interface SessionsRouterDeps { sandboxProviderStore: ISandboxProviderStore; redis?: RedisClientType | undefined; requestReplyRouter: RequestReplyRouter; - resolveUserContext: ResolveUserContext; + resolveRequestContext: ResolveRequestContext; logger: Logger; } @@ -217,8 +214,8 @@ async function freezeTurnIgnoringMissing( const FORBIDDEN_SESSION_ACCESS = 'Only the session creator can access this session'; -function checkSessionAccess({ userRef, createdBy }: { userRef: string; createdBy: string }): boolean { - return userRef === createdBy; +function checkSessionAccess({ subject_id, createdBy }: { subject_id: string; createdBy: string }): boolean { + return subject_id === createdBy; } type InternalSessionsRouterDeps = Pick< @@ -229,7 +226,7 @@ type InternalSessionsRouterDeps = Pick< | 'skillStore' | 'agentStore' | 'sandboxProviderStore' - | 'resolveUserContext' + | 'resolveRequestContext' >; function createGetOrCreateSessionByExternalIdHandler( @@ -237,14 +234,14 @@ function createGetOrCreateSessionByExternalIdHandler( ): RouteHandler { return async c => { const body = c.req.valid('json'); - const user = deps.resolveUserContext(c); + const requestContext = deps.resolveRequestContext(c); const existing = await deps.sessions.getByExternalId({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, external_id: body.external_id, }); if (existing !== undefined) { - if (!checkSessionAccess({ userRef: user.userRef, createdBy: existing.record.created_by })) { + if (!checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: existing.record.created_by })) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } return c.json({ data: toWireSession(existing.record) }, 200); @@ -252,7 +249,10 @@ function createGetOrCreateSessionByExternalIdHandler( let agent: SessionRecord['agent']; if (isSessionAgentNameRef(body.agent)) { - const named = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, name: body.agent.name }); + const named = await deps.agentStore.getAgent({ + tenant_id: requestContext.tenant_id, + name: body.agent.name, + }); if (named === undefined) { return c.json({ error: { message: `Agent not found: ${body.agent.name}` } }, 404); } @@ -260,7 +260,7 @@ function createGetOrCreateSessionByExternalIdHandler( } else { await validateAgentSpec({ spec: body.agent.spec, - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), skillStore: deps.skillStore, @@ -270,12 +270,12 @@ function createGetOrCreateSessionByExternalIdHandler( } const { session, created } = await deps.sessions.getOrCreateByExternalId({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, external_id: body.external_id, - created_by: user.userRef, + created_by: requestContext.subject.id, agent, }); - if (!created && !checkSessionAccess({ userRef: user.userRef, createdBy: session.record.created_by })) { + if (!created && !checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: session.record.created_by })) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } return c.json({ data: toWireSession(session.record) }, created ? 201 : 200); @@ -294,17 +294,20 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { const createSessionHandler: RouteHandler = async c => { const body = c.req.valid('json'); const sessionId = newId(); + const requestContext = deps.resolveRequestContext(c); if (isSessionAgentNameRef(body.agent)) { - const agent = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, name: body.agent.name }); + const agent = await deps.agentStore.getAgent({ + tenant_id: requestContext.tenant_id, + name: body.agent.name, + }); if (agent === undefined) { return c.json({ error: { message: `Agent not found: ${body.agent.name}` } }, 404); } - const user = deps.resolveUserContext(c); const session = await deps.sessions.create({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, session_id: sessionId, - created_by: user.userRef, + created_by: requestContext.subject.id, agent: { type: 'reference', id: agent.id, name: agent.name }, metadata: body.metadata, external_id: null, @@ -314,17 +317,16 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { await validateAgentSpec({ spec: body.agent.spec, - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, }); - const user = deps.resolveUserContext(c); const session = await deps.sessions.create({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, session_id: sessionId, - created_by: user.userRef, + created_by: requestContext.subject.id, agent: { type: 'inline', spec: body.agent.spec }, metadata: body.metadata, external_id: null, @@ -334,11 +336,15 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { const getSessionHandler: RouteHandler = async c => { const { session_id: sessionId } = c.req.valid('param'); - const record = await deps.sessionStore.getSession({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const record = await deps.sessionStore.getSession({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!record) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkSessionAccess({ userRef: deps.resolveUserContext(c).userRef, createdBy: record.created_by })) { + if (!checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: record.created_by })) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } return c.json({ data: toWireSession(record) }, 200); @@ -346,26 +352,37 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { const deleteSessionHandler: RouteHandler = async c => { const { session_id: sessionId } = c.req.valid('param'); - const record = await deps.sessionStore.getSession({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const record = await deps.sessionStore.getSession({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!record) { // Idempotent delete when already gone. return c.body(null, 204); } - if (!checkSessionAccess({ userRef: deps.resolveUserContext(c).userRef, createdBy: record.created_by })) { + if (!checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: record.created_by })) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } - await deps.sessionStore.deleteSession({ tenant_id: TENANT_ID, session_id: sessionId }); + await deps.sessionStore.deleteSession({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); return c.body(null, 204); }; const updateSessionHandler: RouteHandler = async c => { const { session_id: sessionId } = c.req.valid('param'); const body = c.req.valid('json'); - const existing = await deps.sessionStore.getSession({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const existing = await deps.sessionStore.getSession({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!existing) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkSessionAccess({ userRef: deps.resolveUserContext(c).userRef, createdBy: existing.created_by })) { + if (!checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: existing.created_by })) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } // Inline sessions may replace their agent; named (reference) sessions @@ -373,7 +390,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { if (body.agent !== undefined) { await validateAgentSpec({ spec: body.agent.spec, - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), skillStore: deps.skillStore, @@ -382,7 +399,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { } try { await deps.sessionStore.updateSession({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, session_id: sessionId, agent: body.agent === undefined ? undefined : { type: 'inline', spec: body.agent.spec }, title: undefined, @@ -397,7 +414,10 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { } throw error; } - const record = await deps.sessionStore.getSession({ tenant_id: TENANT_ID, session_id: sessionId }); + const record = await deps.sessionStore.getSession({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!record) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } @@ -406,12 +426,12 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { const listSessionsHandler: RouteHandler = async c => { const query = c.req.valid('query'); - const user = deps.resolveUserContext(c); + const requestContext = deps.resolveRequestContext(c); try { const { data, pagination } = await deps.sessionStore.listSessions({ agent_id: query.agent_id, - created_by: user.userRef, - tenant_id: TENANT_ID, + created_by: requestContext.subject.id, + tenant_id: requestContext.tenant_id, limit: query.limit, order: query.order, page_token: query.page_token, @@ -429,11 +449,15 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { const cancelSessionHandler: RouteHandler = async c => { const { session_id: sessionId } = c.req.valid('param'); - const session = await deps.sessions.get({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const session = await deps.sessions.get({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkSessionAccess({ userRef: deps.resolveUserContext(c).userRef, createdBy: session.record.created_by })) { + if (!checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: session.record.created_by })) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } const turnId = session.record.last_turn_id; @@ -448,11 +472,15 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { const listSessionEventsHandler: RouteHandler = async c => { const { session_id: sessionId } = c.req.valid('param'); const query = c.req.valid('query'); - const session = await deps.sessions.get({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const session = await deps.sessions.get({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkSessionAccess({ userRef: deps.resolveUserContext(c).userRef, createdBy: session.record.created_by })) { + if (!checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: session.record.created_by })) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } try { diff --git a/packages/trueforge/src/apis/settings.ts b/packages/trueforge/src/apis/settings.ts index 79cd13434..e1f08654a 100644 --- a/packages/trueforge/src/apis/settings.ts +++ b/packages/trueforge/src/apis/settings.ts @@ -6,7 +6,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import type { Context } from 'hono'; import type { Logger } from 'winston'; -import type { ResolveUserContext } from '../auth/identity'; +import type { ResolveRequestContext } from '../auth/identity'; import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; @@ -26,7 +26,7 @@ export interface SettingsRouterDeps { sandboxProviderStore: ISandboxProviderStore; withTransaction: WithTransaction; logger: Logger; - resolveUserContext: ResolveUserContext; + resolveRequestContext: ResolveRequestContext; } export function createSettingsRouter(deps: SettingsRouterDeps) { @@ -36,6 +36,7 @@ export function createSettingsRouter(deps: SettingsRouterDeps(deps: SettingsRouterDeps(deps: SettingsRouterDeps(deps: SettingsRouterDeps { skillStore: ISkillStore; withTransaction: WithTransaction; + resolveRequestContext: ResolveRequestContext; } /** Wire view of a stored skill: identity `name` plus nested manifest. */ @@ -26,16 +27,21 @@ function toConfiguredSkill(record: SkillRecord): ConfiguredSkill { /** Admin/settings skills CRUD (mounted at /api/v1/settings/skills). */ export function createSkillsRouter(deps: SkillsRouterDeps) { const listConfiguredHandler: RouteHandler = async c => { - const records = await deps.skillStore.listSkills({ tenant_id: TENANT_ID, names: undefined }); + const requestContext = deps.resolveRequestContext(c); + const records = await deps.skillStore.listSkills({ + tenant_id: requestContext.tenant_id, + names: undefined, + }); return c.json({ data: records.map(toConfiguredSkill) }, 200); }; const createHandler: RouteHandler = async c => { const body: CreateSkillRequest = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); const manifest = body.manifest; try { const record = await deps.skillStore.createSkill({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name: manifest.name, manifest, }); @@ -50,9 +56,10 @@ export function createSkillsRouter(deps: SkillsRouterDeps = async c => { const body: UpdateSkillRequest = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); const manifest = body.manifest; const record = await deps.skillStore.upsertSkill({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, name: manifest.name, manifest, }); @@ -70,10 +77,15 @@ export function createSkillsRouter(deps: SkillsRouterDeps(deps: { skillStore: ISkillStore; withTransaction: WithTransaction; + resolveRequestContext: ResolveRequestContext; }) { const router = new OpenAPIHono(); router.openapi(listAvailableSkillsRoute, async c => { - const records = await deps.skillStore.listSkills({ tenant_id: TENANT_ID, names: undefined }); + const requestContext = deps.resolveRequestContext(c); + const records = await deps.skillStore.listSkills({ + tenant_id: requestContext.tenant_id, + names: undefined, + }); return c.json( { data: records.map(record => ({ diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index f026fd5a4..ca9fece13 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -29,7 +29,7 @@ import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { streamSSE } from 'hono/streaming'; import type { Logger } from 'winston'; -import type { ResolveUserContext, UserContext } from '../auth/identity'; +import type { ResolveRequestContext } from '../auth/identity'; import configuration from '../config'; import type { IAgentStore } from '../db/agentStore'; import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; @@ -57,7 +57,6 @@ import { resolveSandboxProvider, } from '../runtime/sessionResources'; import { checkSnapshotStatus } from '../sandbox/providerUtils'; -import { TENANT_ID } from './sessions'; export function toWireTurn(record: TurnRecordWithoutSnapshot): Turn { return { @@ -114,7 +113,7 @@ export interface TurnsRouterDeps { eventSubscriptions: EventSubscriptionRegistry; sandboxProviderStore: ISandboxProviderStore; logger: Logger; - resolveUserContext: ResolveUserContext; + resolveRequestContext: ResolveRequestContext; } /** @@ -143,6 +142,7 @@ function createTurnResolver(deps: { modelProviderStore: IModelProviderStore; logger: Logger; signal: AbortSignal; + tenant_id: string; userRef: string; sessionId: string; }): TurnResourceResolver { @@ -155,13 +155,14 @@ function createTurnResolver(deps: { modelProviderStore, logger, signal, + tenant_id, userRef, sessionId, } = deps; return new TurnResourceResolver({ llm: async name => { const resolved = await getModelDetails({ - tenant_id: TENANT_ID, + tenant_id, name, store: modelProviderStore, }); @@ -177,7 +178,7 @@ function createTurnResolver(deps: { }, mcp: async name => { const connection = await getMcpConnection({ - tenant_id: TENANT_ID, + tenant_id, name, store: mcpServerStore, tokenStore, @@ -195,7 +196,7 @@ function createTurnResolver(deps: { mcpConnectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, sandboxProvider: async ({ spec, existingSandboxId, tracing }) => { const provider = await resolveSandboxProvider({ - tenant_id: TENANT_ID, + tenant_id, store: sandboxProviderStore, logger, sessionId, @@ -213,7 +214,7 @@ function createTurnResolver(deps: { // Restoring an existing sandbox goes through daytona.get and never touches the snapshot. // Local fallback has no image build. if (carriedSandboxId === undefined && provider.type !== 'local') { - const status = await checkSnapshotStatus({ store: sandboxProviderStore, tenant_id: TENANT_ID, logger }); + const status = await checkSnapshotStatus({ store: sandboxProviderStore, tenant_id, logger }); if (status?.status !== 'ready') { throw new HTTPException(422, { message: @@ -224,7 +225,7 @@ function createTurnResolver(deps: { } } const gitSkills = await resolveGitSkills({ - tenant_id: TENANT_ID, + tenant_id, skills: spec.skills ?? [], store: skillStore, }); @@ -238,7 +239,7 @@ function createTurnResolver(deps: { }); }, agent: async agentId => { - const record = await agentStore.getAgent({ tenant_id: TENANT_ID, id: agentId }); + const record = await agentStore.getAgent({ tenant_id, id: agentId }); if (record === undefined) { throw new HTTPException(422, { message: `Agent not found: ${agentId}` }); } @@ -374,6 +375,7 @@ export async function beginTurnExecution(params: { const sessionId = session.session_id; const abortController = new AbortController(); + const tenant_id = session.tenant_id; const resolver = createTurnResolver({ mcpServerStore: deps.mcpServerStore, tokenStore: deps.tokenStore, @@ -383,6 +385,7 @@ export async function beginTurnExecution(params: { modelProviderStore: deps.modelProviderStore, logger: deps.logger, signal: abortController.signal, + tenant_id, userRef, sessionId, }); @@ -415,7 +418,7 @@ export async function beginTurnExecution(params: { }); // Held for the whole turn; the stream's sequence counter dies with it. - const turnEventStream = deps.eventSubscriptions.get(turnStreamId(TENANT_ID, sessionId, turn.id)); + const turnEventStream = deps.eventSubscriptions.get(turnStreamId(tenant_id, sessionId, turn.id)); return { turn, @@ -512,9 +515,9 @@ export function resolveAfterSequenceNumber(c: Context, bodyAfterSequenceNumber?: return bodyAfterSequenceNumber; } -/** True when `user` is the session creator (`created_by`). */ -function checkTurnAccess(user: UserContext, createdBy: string): boolean { - return createdBy === user.userRef; +/** True when the subject is the session creator (`created_by`). */ +function checkTurnAccess({ subject_id, createdBy }: { subject_id: string; createdBy: string }): boolean { + return createdBy === subject_id; } const FORBIDDEN_SESSION_ACCESS = 'Only the session creator can access this session'; @@ -525,11 +528,20 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { const listTurnsHandler: RouteHandler = async c => { const { session_id: sessionId } = c.req.valid('param'); const query = c.req.valid('query'); - const session = await deps.sessions.get({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const session = await deps.sessions.get({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkTurnAccess(deps.resolveUserContext(c), session.record.created_by)) { + if ( + !checkTurnAccess({ + subject_id: requestContext.subject.id, + createdBy: session.record.created_by, + }) + ) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } try { @@ -548,11 +560,20 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { const getTurnHandler: RouteHandler = async c => { const { session_id: sessionId, turn_id: turnId } = c.req.valid('param'); - const session = await deps.sessions.get({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const session = await deps.sessions.get({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkTurnAccess(deps.resolveUserContext(c), session.record.created_by)) { + if ( + !checkTurnAccess({ + subject_id: requestContext.subject.id, + createdBy: session.record.created_by, + }) + ) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } const turn = await session.getTurn(turnId); @@ -571,11 +592,20 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { // Cheapest first: a malformed path costs no store read and no provider round-trip. validateSandboxFilePath(path); - const session = await deps.sessions.get({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const session = await deps.sessions.get({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkTurnAccess(deps.resolveUserContext(c), session.record.created_by)) { + if ( + !checkTurnAccess({ + subject_id: requestContext.subject.id, + createdBy: session.record.created_by, + }) + ) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } @@ -591,7 +621,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { } const provider = await resolveSandboxProvider({ - tenant_id: TENANT_ID, + tenant_id: requestContext.tenant_id, store: deps.sandboxProviderStore, logger: deps.logger, sessionId, @@ -627,11 +657,20 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { const listTurnEventsHandler: RouteHandler = async c => { const { session_id: sessionId, turn_id: turnId } = c.req.valid('param'); const query = c.req.valid('query'); - const session = await deps.sessions.get({ tenant_id: TENANT_ID, session_id: sessionId }); + const requestContext = deps.resolveRequestContext(c); + const session = await deps.sessions.get({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkTurnAccess(deps.resolveUserContext(c), session.record.created_by)) { + if ( + !checkTurnAccess({ + subject_id: requestContext.subject.id, + createdBy: session.record.created_by, + }) + ) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } const turn = await session.getTurn(turnId); @@ -656,21 +695,29 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { const createAndExecuteTurnHandler: RouteHandler = async c => { const { session_id: sessionId } = c.req.valid('param'); const body = c.req.valid('json'); + const requestContext = deps.resolveRequestContext(c); - const session = await deps.sessions.get({ tenant_id: TENANT_ID, session_id: sessionId }); + const session = await deps.sessions.get({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkTurnAccess(deps.resolveUserContext(c), session.record.created_by)) { + if ( + !checkTurnAccess({ + subject_id: requestContext.subject.id, + createdBy: session.record.created_by, + }) + ) { return c.json({ error: { message: FORBIDDEN_CREATE_TURN } }, 403); } - const userRef = deps.resolveUserContext(c).userRef; const turnParams = { session, input: body.input, previous_turn_id: body.previous_turn_id, - userRef, + userRef: requestContext.subject.id, deps: { ...deps, modelProviderStore: deps.resolveModelProviderStore(c), @@ -720,12 +767,21 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { const { session_id: sessionId, turn_id: turnId } = c.req.valid('param'); const query = c.req.valid('query'); const afterSequenceNumber = resolveAfterSequenceNumber(c, query.after_sequence_number); + const requestContext = deps.resolveRequestContext(c); - const session = await deps.sessions.get({ tenant_id: TENANT_ID, session_id: sessionId }); + const session = await deps.sessions.get({ + tenant_id: requestContext.tenant_id, + session_id: sessionId, + }); if (!session) { return c.json({ error: { message: `Session not found: ${sessionId}` } }, 404); } - if (!checkTurnAccess(deps.resolveUserContext(c), session.record.created_by)) { + if ( + !checkTurnAccess({ + subject_id: requestContext.subject.id, + createdBy: session.record.created_by, + }) + ) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } const turn = await session.getTurn(turnId); @@ -733,7 +789,9 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { return c.json({ error: { message: `Turn not found: ${turnId}` } }, 404); } - const turnEventStream = deps.eventSubscriptions.get(turnStreamId(TENANT_ID, sessionId, turnId)); + const turnEventStream = deps.eventSubscriptions.get( + turnStreamId(requestContext.tenant_id, sessionId, turnId), + ); // Admission check before SSE headers are sent, so it can still map to HTTP 412. try { diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index 930ae409b..a8af84661 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -23,8 +23,13 @@ import { createInternalSessionsRouter, createSessionsRouter } from './apis/sessi import { createSettingsRouter } from './apis/settings'; import { createAvailableSkillsRouter } from './apis/skills'; import { createTurnsRouter } from './apis/turns'; -import { resolveUserContext } from './auth/identity'; -import { adminAuthMiddleware, authMiddleware } from './auth/middleware'; +import { + createAdminAuthMiddleware, + createAuthMiddleware, + type Authenticator, +} from './auth/authenticator'; +import { resolveRequestContext } from './auth/identity'; +import { StandaloneAuthenticator } from './auth/standaloneAuthenticator'; import type { McpCatalog } from './catalog/McpCatalog'; import type { ModelCatalog } from './catalog/ModelCatalog'; import type { SandboxCatalog } from './catalog/SandboxCatalog'; @@ -103,9 +108,9 @@ const openApiDocConfig = { description: 'HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` ' + '(OpenAPI JSON at `/api/v1/openapi.json`).\n\n' + - '**Authentication:** Standalone deployments (no OIDC) accept requests without credentials — middleware ' + - 'stamps a local default user. When OIDC is configured, protected routes require a valid `id_token` cookie ' + - 'or `Authorization: Bearer` ID token. There is no built-in API-key scheme; ' + + '**Authentication:** Standalone auth accepts requests without credentials — middleware ' + + 'stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid ' + + 'cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; ' + 'pass custom headers only if your reverse proxy or IdP layer requires them.\n\n' + 'Covers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.', version: PACKAGE_VERSION, @@ -120,8 +125,8 @@ export function registerOpenApiBearerAuth(app: OpenAPIHono): void { scheme: 'bearer', bearerFormat: 'JWT', description: - 'ID token (`Authorization: Bearer `). Required on protected routes. ' + - 'Browser sessions may use the HttpOnly `id_token` cookie instead.', + 'Caller credential (`Authorization: Bearer `). Required on protected routes when auth is enabled. ' + + 'Browser sessions may use the HttpOnly `id_token` or `accessToken` cookie instead.', }); } @@ -144,22 +149,6 @@ function routeNotFound(c: Context) { return c.json({ error: { message: `Route not found: ${c.req.method} ${c.req.path}` } }, 404); } -/** Sub-app shell: `.use('*', authMiddleware)` then child routes — same as gateway routers. */ -function withAuth(router: OpenAPIHono): OpenAPIHono { - const shell = new OpenAPIHono(); - shell.use('*', authMiddleware); - shell.route('/', router); - return shell; -} - -/** Admin-only routes: local admin when auth is disabled; with OIDC requires an authenticated admin. */ -function withAdminAuth(router: OpenAPIHono): OpenAPIHono { - const shell = new OpenAPIHono(); - shell.use('*', adminAuthMiddleware); - shell.route('/', router); - return shell; -} - export interface ServerDeps { modelCatalog: ModelCatalog; mcpCatalog: McpCatalog; @@ -194,11 +183,29 @@ export interface ServerDeps { logger: Logger; /** Discovered openid-client configuration; undefined when browser login is disabled. */ oidcClient: Configuration | undefined; + /** Startup-selected authenticator; middleware is built from this once per app. */ + authenticator: Authenticator; } export function createServerApp(deps: ServerDeps) { const app = new OpenAPIHono({ defaultHook: zodValidationHook }); - const authEnabled = deps.oidcClient != null; + const authMiddleware = createAuthMiddleware(deps.authenticator); + const adminAuthMiddleware = createAdminAuthMiddleware(deps.authenticator); + const authEnabled = !(deps.authenticator instanceof StandaloneAuthenticator); + + function withAuth(router: OpenAPIHono): OpenAPIHono { + const shell = new OpenAPIHono(); + shell.use('*', authMiddleware); + shell.route('/', router); + return shell; + } + + function withAdminAuth(router: OpenAPIHono): OpenAPIHono { + const shell = new OpenAPIHono(); + shell.use('*', adminAuthMiddleware); + shell.route('/', router); + return shell; + } if (configuration.ACCESS_LOGS) { app.use('*', createAccessLogMiddleware(deps.logger)); @@ -207,7 +214,14 @@ export function createServerApp(deps: ServerDeps) { app.get('/healthz', c => c.json({ status: 'ok', version: PACKAGE_VERSION })); - app.route('/api/v1/auth', createAuthRouter({ oidcClient: deps.oidcClient, logger: deps.logger })); + app.route( + '/api/v1/auth', + createAuthRouter({ + oidcClient: deps.oidcClient, + logger: deps.logger, + authMiddleware, + }), + ); app.route( '/api/v1/capabilities', withAuth( @@ -215,6 +229,7 @@ export function createServerApp(deps: ServerDeps) { sandboxProviderStore: deps.sandboxProviderStore, withTransaction: deps.withTransaction, logger: deps.logger, + resolveRequestContext, }), ), ); @@ -224,6 +239,7 @@ export function createServerApp(deps: ServerDeps) { createModelsRouter({ resolveModelProviderStore: deps.resolveModelProviderStore, withTransaction: deps.withTransaction, + resolveRequestContext, }), ), ); @@ -256,7 +272,7 @@ export function createServerApp(deps: ServerDeps) { tokenStore: deps.tokenStore, withTransaction: deps.withTransaction, logger: deps.logger, - resolveUserContext, + resolveRequestContext, }), ), ); @@ -266,6 +282,7 @@ export function createServerApp(deps: ServerDeps) { createAvailableSkillsRouter({ skillStore: deps.skillStore, withTransaction: deps.withTransaction, + resolveRequestContext, }), ), ); @@ -279,6 +296,7 @@ export function createServerApp(deps: ServerDeps) { skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, withTransaction: deps.withTransaction, + resolveRequestContext, }), ), ); @@ -301,7 +319,7 @@ export function createServerApp(deps: ServerDeps) { logger: deps.logger, }, withTransaction: deps.withTransaction, - resolveUserContext, + resolveRequestContext, }), ), ); @@ -316,7 +334,7 @@ export function createServerApp(deps: ServerDeps) { sandboxProviderStore: deps.sandboxProviderStore, withTransaction: deps.withTransaction, logger: deps.logger, - resolveUserContext, + resolveRequestContext, }), ), ); @@ -330,7 +348,7 @@ export function createServerApp(deps: ServerDeps) { skillStore: deps.skillStore, agentStore: deps.agentStore, sandboxProviderStore: deps.sandboxProviderStore, - resolveUserContext, + resolveRequestContext, }), ), ); @@ -339,7 +357,7 @@ export function createServerApp(deps: ServerDeps) { withAuth( createInternalMetricsRouter({ sessionMetricsStore: deps.sessionMetricsStore, - resolveUserContext, + resolveRequestContext, }), ), ); @@ -357,7 +375,7 @@ export function createServerApp(deps: ServerDeps) { sandboxProviderStore: deps.sandboxProviderStore, redis: deps.redis, requestReplyRouter: deps.requestReplyRouter, - resolveUserContext: resolveUserContext, + resolveRequestContext, logger: deps.logger, }), ), @@ -377,7 +395,7 @@ export function createServerApp(deps: ServerDeps) { eventSubscriptions: deps.eventSubscriptions, sandboxProviderStore: deps.sandboxProviderStore, logger: deps.logger, - resolveUserContext: resolveUserContext, + resolveRequestContext, }), ), ); diff --git a/packages/trueforge/src/auth/authenticator.ts b/packages/trueforge/src/auth/authenticator.ts new file mode 100644 index 000000000..04f112e7a --- /dev/null +++ b/packages/trueforge/src/auth/authenticator.ts @@ -0,0 +1,33 @@ +import type { Context, MiddlewareHandler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +import type { RequestContext } from './identity'; + +// tells TypeScript that c.set('request_context', …) / c.get('request_context') are valid and return RequestContextt +declare module 'hono' { + interface ContextVariableMap { + request_context?: RequestContext; + } +} + +export interface Authenticator { + authenticate(c: Context): Promise; +} + +export function createAuthMiddleware(authenticator: Authenticator): MiddlewareHandler { + return async (c, next) => { + c.set('request_context', await authenticator.authenticate(c)); + return next(); + }; +} + +export function createAdminAuthMiddleware(authenticator: Authenticator): MiddlewareHandler { + return async (c, next) => { + const requestContext = await authenticator.authenticate(c); + if (!requestContext.is_admin) { + throw new HTTPException(403, { message: 'Admin access required' }); + } + c.set('request_context', requestContext); + return next(); + }; +} diff --git a/packages/trueforge/src/auth/claims.ts b/packages/trueforge/src/auth/claims.ts index 1978183c9..e700ee191 100644 --- a/packages/trueforge/src/auth/claims.ts +++ b/packages/trueforge/src/auth/claims.ts @@ -1,6 +1,6 @@ import type { OIDCConfig } from '../config'; import { assertEmailAllowed } from './emailAllowlist'; -import type { Role, UserContext } from './identity'; +import type { RequestContext } from './identity'; /** Raw claims from a decoded ID token; values are untyped until read here. */ export type IdTokenClaims = Record; @@ -23,13 +23,22 @@ export function claimValues(claim: unknown): string[] { return []; } +/** Non-empty string claim, or undefined when absent / empty / wrong type. */ +export function resolveOptionalStringClaim(claims: IdTokenClaims, claimName: string): string | undefined { + const value = claims[claimName]; + if (typeof value !== 'string' || value === '') { + return undefined; + } + return value; +} + /** * The stable identity key for this caller. An identity that can't be resolved * means the token can't be trusted to identify anyone, so this throws. */ export function resolveUserRef(claims: IdTokenClaims, config: OIDCConfig): string { - const value = claims[config.OIDC_USER_REFERENCE_CLAIM]; - if (typeof value !== 'string' || value === '') { + const value = resolveOptionalStringClaim(claims, config.OIDC_USER_REFERENCE_CLAIM); + if (value === undefined) { throw new Error( `ID token is missing a non-empty "${config.OIDC_USER_REFERENCE_CLAIM}" claim (OIDC_USER_REFERENCE_CLAIM).`, ); @@ -41,16 +50,31 @@ export function resolveUserRef(claims: IdTokenClaims, config: OIDCConfig): strin * Admin iff the configured role claim's values include the configured admin * value, exact case-sensitive string match. */ -export function resolveRole(claims: IdTokenClaims, config: OIDCConfig): Role { +export function resolveRole(claims: IdTokenClaims, config: OIDCConfig): 'admin' | 'user' { return claimValues(claims[config.OIDC_USER_ROLE_CLAIM]).includes(config.OIDC_ADMIN_ROLE_VALUE) ? 'admin' : 'user'; } -/** Everything `/callback` (and later `/me`, once sessions are real) needs from a set of claims. */ -export function toUserContext(claims: IdTokenClaims, config: OIDCConfig): UserContext { +/** Map verified ID-token claims onto a {@link RequestContext}. */ +export function toRequestContext(params: { + claims: IdTokenClaims; + config: OIDCConfig; + authorization: string; +}): RequestContext { + const { claims, config, authorization } = params; assertEmailAllowed(claims, config); + const subjectId = resolveUserRef(claims, config); + const role = resolveRole(claims, config); + const displayName = + resolveOptionalStringClaim(claims, config.OIDC_USER_DISPLAY_NAME_CLAIM) ?? subjectId; return { - userRef: resolveUserRef(claims, config), - role: resolveRole(claims, config), + tenant_id: 'default', + subject: { + id: subjectId, + type: 'user', + display_name: displayName, + }, + is_admin: role === 'admin', + user_credential: { authorization }, }; } @@ -63,10 +87,8 @@ export interface AuthorizationRequestParams { /** * Scopes + `claims` parameter for the authorization request, derived * from configured claim names and {@link OIDCConfig.OIDC_SCOPES}. - * `essential: true` on the role claim makes the IdP reject the - * login outright if it can't actually produce that claim (e.g. a broken - * claim mapping) instead of silently omitting it and defaulting the user to - * non-admin. + * `essential: true` on identity claims makes the IdP reject the + * login outright if it can't actually produce that claim. * When an email allowlist is configured, `email` is also marked essential so * the IdP cannot complete login without a usable address to check. */ @@ -74,6 +96,7 @@ export function buildAuthorizationRequestParams(config: OIDCConfig): Authorizati const idTokenClaims: Record = { [config.OIDC_USER_REFERENCE_CLAIM]: { essential: true }, [config.OIDC_USER_ROLE_CLAIM]: { essential: true }, + [config.OIDC_USER_DISPLAY_NAME_CLAIM]: { essential: true }, }; if (config.OIDC_ALLOWED_EMAILS.length > 0) { idTokenClaims['email'] = { essential: true }; diff --git a/packages/trueforge/src/auth/createAuthenticator.ts b/packages/trueforge/src/auth/createAuthenticator.ts new file mode 100644 index 000000000..99b99e4d1 --- /dev/null +++ b/packages/trueforge/src/auth/createAuthenticator.ts @@ -0,0 +1,30 @@ +import type { TrueFoundryServiceFoundryServerClient } from '../truefoundry/TrueFoundryServiceFoundryServerClient'; +import type { Authenticator } from './authenticator'; +import { OidcAuthenticator } from './oidcAuthenticator'; +import { StandaloneAuthenticator } from './standaloneAuthenticator'; +import { TrueFoundryAuthenticator } from './trueFoundryAuthenticator'; + +export enum TrueforgeMode { + Standalone = 'standalone', + Oidc = 'oidc', + TrueFoundry = 'truefoundry', +} + +/** Build the single process authenticator from resolved auth mode. */ +export function createAuthenticator(params: { + mode: TrueforgeMode; + trueFoundryClient?: TrueFoundryServiceFoundryServerClient; +}): Authenticator { + switch (params.mode) { + case TrueforgeMode.TrueFoundry: { + if (!params.trueFoundryClient) { + throw new Error('TrueFoundryAuthenticator requires a ServiceFoundry client'); + } + return new TrueFoundryAuthenticator(params.trueFoundryClient); + } + case TrueforgeMode.Oidc: + return new OidcAuthenticator(); + case TrueforgeMode.Standalone: + return new StandaloneAuthenticator(); + } +} diff --git a/packages/trueforge/src/auth/identity.ts b/packages/trueforge/src/auth/identity.ts index fc46a1ab6..484c95d2b 100644 --- a/packages/trueforge/src/auth/identity.ts +++ b/packages/trueforge/src/auth/identity.ts @@ -1,41 +1,43 @@ import type { Context } from 'hono'; -import { getOidcVerify } from './oidc'; +import { z } from 'zod'; -export type Role = 'admin' | 'user'; +export const SubjectTypeSchema = z.enum(['user', 'virtualaccount']); +export type SubjectType = z.infer; -export interface UserContext { - userRef: string; - role: Role; +export interface RequestSubject { + id: string; + type: SubjectType; + display_name: string; } -/** - * Fixed identity when no identity provider is configured (standalone / auth disabled). - * Stamped onto sessions as `created_by` and used for ownership checks. - */ -export const LOCAL_USER_CONTEXT: UserContext = { - userRef: 'trueforge-default', - role: 'admin', -}; +export interface UserCredential { + authorization: string; +} -/** Auth disabled: always true. Auth enabled: admin role only. */ -export function isAdmin(user: UserContext): boolean { - if (!getOidcVerify()) { - return true; - } - return user.role === 'admin'; +export interface RequestContext { + tenant_id: string; + subject: RequestSubject; + is_admin: boolean; + user_credential: UserCredential | null; } -/** Resolves the caller identity from the request context. Injected on session/turn routers. */ -export type ResolveUserContext = (c: Context) => UserContext; +export const STANDALONE_REQUEST_CONTEXT: RequestContext = { + tenant_id: 'default', + subject: { + id: 'trueforge-default', + type: 'user', + display_name: 'Admin', + }, + is_admin: true, + user_credential: null, +}; + +export type ResolveRequestContext = (c: Context) => RequestContext; -/** - * Caller {@link UserContext} for the current request. - * Requires auth middleware to have set `c.var.user`. - */ -export function resolveUserContext(c: Context): UserContext { - const uc = c.get('user_context'); - if (uc === undefined) { - throw new Error('UserContext missing; auth middleware did not run'); +export function resolveRequestContext(c: Context): RequestContext { + const requestContext = c.get('request_context'); + if (requestContext === undefined) { + throw new Error('RequestContext missing; auth middleware did not run'); } - return uc; + return requestContext; } diff --git a/packages/trueforge/src/auth/middleware.ts b/packages/trueforge/src/auth/middleware.ts index 2d2bf2f94..49c742f29 100644 --- a/packages/trueforge/src/auth/middleware.ts +++ b/packages/trueforge/src/auth/middleware.ts @@ -1,69 +1,25 @@ -import type { Context, MiddlewareHandler } from 'hono'; -import { HTTPException } from 'hono/http-exception'; +import type { Context } from 'hono'; import { jwtVerify } from 'jose'; -import { toUserContext, type IdTokenClaims } from './claims'; -import { readAccessTokenCookie, readIdTokenCookie } from './cookies'; -import { LOCAL_USER_CONTEXT, isAdmin, type UserContext } from './identity'; -import { getOidcVerify } from './oidc'; - -declare module 'hono' { - interface ContextVariableMap { - user_context?: UserContext; - } -} - -const AUTH_HEADER_TYPE = 'Bearer'; -/** - * Bearer token from `Authorization: Bearer ` when present. - * Case-insensitive scheme; rejects empty credentials. Non-Bearer schemes → undefined - * so cookie auth can still apply. - */ -export function readBearerToken(c: Context): string | undefined { - const header = c.req.header('Authorization')?.trim(); - if (!header) { - return undefined; - } - const prefix = `${AUTH_HEADER_TYPE} `; - if (!header.toLowerCase().startsWith(prefix.toLowerCase())) { - return undefined; - } - const token = header.slice(prefix.length).trim(); - return token.length > 0 ? token : undefined; -} - -export function readAccessToken(c: Context): string | undefined { - return readBearerToken(c) ?? readAccessTokenCookie({ context: c }) ?? readIdTokenCookie({ context: c }); -} - -export function requireAccessToken(c: Context): string { - const token = readAccessToken(c); - if (!token) { - throw new HTTPException(401, { - message: 'Authentication token required to list or call TrueFoundry models and MCP servers', - }); - } - return token; -} +import { toRequestContext, type IdTokenClaims } from './claims'; +import type { RequestContext } from './identity'; +import { getOidcVerify } from './oidc'; +import { extractRequestToken, toBearerAuthorization } from './token'; -/** - * Prefer Bearer over the browser cookie when both are sent (explicit API auth wins). - */ -export function readIdToken(c: Context): string | undefined { - return readBearerToken(c) ?? readIdTokenCookie({ context: c }); -} +export { extractRequestToken, readBearerToken, toBearerAuthorization } from './token'; /** - * Bearer or cookie ID token → {@link UserContext} when auth is enabled and the JWT is valid. + * Bearer or cookie token → {@link RequestContext} when OIDC is enabled and the JWT is valid. * Missing/invalid JWT → `undefined`. Claim mapping failures after a successful verify rethrow. + * Used by OIDC login soft-probes (callback already-authenticated redirect). */ -export async function resolveAuthUser(c: Context): Promise { +export async function resolveOidcRequestContext(c: Context): Promise { const oidcVerify = getOidcVerify(); if (!oidcVerify) { return undefined; } - const token = readIdToken(c); + const token = extractRequestToken(c); if (!token) { return undefined; } @@ -79,54 +35,9 @@ export async function resolveAuthUser(c: Context): Promise { - if (!getOidcVerify()) { - c.set('user_context', LOCAL_USER_CONTEXT); - return next(); - } - - try { - const user = await resolveAuthUser(c); - if (!user) { - throw new HTTPException(401, { message: 'Authentication required' }); - } - c.set('user_context', user); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - // Valid JWT but claim mapping failed (e.g. missing user reference claim). - throw new HTTPException(401, { message: 'Authentication required', cause: error }); - } - return next(); -}; - -/** Admin gate: local admin when auth is disabled; when auth is enabled requires an authenticated admin. */ -export const adminAuthMiddleware: MiddlewareHandler = async (c, next) => { - if (!getOidcVerify()) { - c.set('user_context', LOCAL_USER_CONTEXT); - return next(); - } - - try { - const user = await resolveAuthUser(c); - if (!user) { - throw new HTTPException(401, { message: 'Authentication required' }); - } - if (!isAdmin(user)) { - throw new HTTPException(403, { message: 'Admin access required' }); - } - c.set('user_context', user); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - throw new HTTPException(401, { message: 'Authentication required', cause: error }); - } - - return next(); -}; diff --git a/packages/trueforge/src/auth/oidcAuthenticator.ts b/packages/trueforge/src/auth/oidcAuthenticator.ts new file mode 100644 index 000000000..f8be6367b --- /dev/null +++ b/packages/trueforge/src/auth/oidcAuthenticator.ts @@ -0,0 +1,44 @@ +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { jwtVerify } from 'jose'; + +import { toRequestContext, type IdTokenClaims } from './claims'; +import type { Authenticator } from './authenticator'; +import type { RequestContext } from './identity'; +import { getOidcVerify } from './oidc'; +import { extractRequestToken, toBearerAuthorization } from './token'; + +export class OidcAuthenticator implements Authenticator { + async authenticate(c: Context): Promise { + const oidcVerify = getOidcVerify(); + if (!oidcVerify) { + throw new HTTPException(401, { message: 'Authentication required' }); + } + + const token = extractRequestToken(c); + if (!token) { + throw new HTTPException(401, { message: 'Authentication required' }); + } + + let payload: Awaited>['payload']; + try { + ({ payload } = await jwtVerify(token, oidcVerify.jwks, { + issuer: oidcVerify.issuer, + audience: oidcVerify.audience, + })); + } catch { + throw new HTTPException(401, { message: 'Authentication required' }); + } + + try { + const claims: IdTokenClaims = { ...payload }; + return toRequestContext({ + claims, + config: oidcVerify.oidcConfig, + authorization: toBearerAuthorization(token), + }); + } catch (error) { + throw new HTTPException(401, { message: 'Authentication required', cause: error }); + } + } +} diff --git a/packages/trueforge/src/auth/standaloneAuthenticator.ts b/packages/trueforge/src/auth/standaloneAuthenticator.ts new file mode 100644 index 000000000..f4708d819 --- /dev/null +++ b/packages/trueforge/src/auth/standaloneAuthenticator.ts @@ -0,0 +1,8 @@ +import type { Authenticator } from './authenticator'; +import { STANDALONE_REQUEST_CONTEXT, type RequestContext } from './identity'; + +export class StandaloneAuthenticator implements Authenticator { + authenticate(): Promise { + return Promise.resolve(STANDALONE_REQUEST_CONTEXT); + } +} diff --git a/packages/trueforge/src/auth/token.ts b/packages/trueforge/src/auth/token.ts new file mode 100644 index 000000000..04b151c30 --- /dev/null +++ b/packages/trueforge/src/auth/token.ts @@ -0,0 +1,50 @@ +import type { Context } from 'hono'; + +import { readAccessTokenCookie, readIdTokenCookie } from './cookies'; + +const AUTH_HEADER_TYPE = 'Bearer'; +const BEARER_PREFIX = `${AUTH_HEADER_TYPE} `; + +/** + * Parse `Bearer ` from an Authorization-style value. + * Case-insensitive scheme; empty credentials → undefined. + */ +function parseBearerAuthorization(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed.toLowerCase().startsWith(BEARER_PREFIX.toLowerCase())) { + return undefined; + } + const token = trimmed.slice(BEARER_PREFIX.length).trim(); + return token.length > 0 ? token : undefined; +} + +/** + * Bearer token from `Authorization: Bearer ` when present. + * Non-Bearer schemes → undefined so cookie auth can still apply. + */ +export function readBearerToken(c: Context): string | undefined { + const header = c.req.header('Authorization'); + if (!header) { + return undefined; + } + return parseBearerAuthorization(header); +} + +/** + * Prefer an explicit Bearer over browser cookies. + * Cookie order: `accessToken`, then `id_token`. + * Never log the returned value. + */ +export function extractRequestToken(c: Context): string | undefined { + return readBearerToken(c) ?? readAccessTokenCookie({ context: c }) ?? readIdTokenCookie({ context: c }); +} + +/** Format a raw token as the `UserCredential.authorization` value. */ +export function toBearerAuthorization(token: string): string { + return `${BEARER_PREFIX}${token}`; +} + +/** Strip the `Bearer ` prefix from a stored credential authorization value. */ +export function rawTokenFromCredential(authorization: string): string { + return parseBearerAuthorization(authorization) ?? authorization; +} diff --git a/packages/trueforge/src/auth/trueFoundryAuthenticator.ts b/packages/trueforge/src/auth/trueFoundryAuthenticator.ts new file mode 100644 index 000000000..e9d03d177 --- /dev/null +++ b/packages/trueforge/src/auth/trueFoundryAuthenticator.ts @@ -0,0 +1,60 @@ +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +import type { GetSessionResponse } from '../truefoundry/TrueFoundryServiceFoundryServerClient'; +import type { Authenticator } from './authenticator'; +import { SubjectTypeSchema, type RequestContext, type SubjectType } from './identity'; +import { extractRequestToken, toBearerAuthorization } from './token'; + +const TENANT_ADMIN_ROLE = 'tenant-admin'; + +/** Narrow port used by the authenticator (avoids depending on the full SFY client). */ +export interface TrueFoundrySessionClient { + getSession(accessToken: string): Promise; +} + +function mapSubjectType(raw: string): SubjectType | undefined { + if (raw === 'user' || raw === 'virtualaccount') { + return SubjectTypeSchema.parse(raw); + } + // sfy server uses 'serviceaccount' for virtual accounts + if (raw === 'serviceaccount') { + return 'virtualaccount'; + } + return undefined; +} + +export class TrueFoundryAuthenticator implements Authenticator { + readonly #client: TrueFoundrySessionClient; + + constructor(client: TrueFoundrySessionClient) { + this.#client = client; + } + + async authenticate(c: Context): Promise { + const token = extractRequestToken(c); + if (!token) { + throw new HTTPException(401, { message: 'Authentication required' }); + } + + const session = await this.#client.getSession(token); + const subjectType = mapSubjectType(session.user.subject.subjectType); + if (subjectType === undefined) { + throw new HTTPException(502, { + message: 'TrueFoundry session returned an unsupported subject type', + }); + } + + const { subject } = session.user; + return { + tenant_id: session.user.tenantName, + subject: { + id: subject.subjectId, + type: subjectType, + display_name: subject.subjectDisplayName ?? subject.subjectSlug ?? subject.subjectId, + }, + is_admin: session.user.roles.includes(TENANT_ADMIN_ROLE), + user_credential: { authorization: toBearerAuthorization(token) }, + }; + } +} diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 619844167..de6a5881f 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -40,6 +40,7 @@ const DEFAULT_POSTGRES_PORT = 5432; const DEFAULT_REDIS_URL = 'redis://localhost:6379'; const DEFAULT_OIDC_USER_REFERENCE_CLAIM = 'sub'; +const DEFAULT_OIDC_USER_DISPLAY_NAME_CLAIM = 'name'; const DEFAULT_OIDC_USER_ROLE_CLAIM = 'groups'; const DEFAULT_OIDC_ADMIN_ROLE_VALUE = 'admin'; const DEFAULT_OIDC_SCOPES = 'openid,profile,email'; @@ -270,6 +271,9 @@ function resolveOIDCConfig(): OIDCConfig | undefined { OIDC_USER_REFERENCE_CLAIM: getEnv('OIDC_USER_REFERENCE_CLAIM', { defaultValue: DEFAULT_OIDC_USER_REFERENCE_CLAIM }) ?? DEFAULT_OIDC_USER_REFERENCE_CLAIM, + OIDC_USER_DISPLAY_NAME_CLAIM: + getEnv('OIDC_USER_DISPLAY_NAME_CLAIM', { defaultValue: DEFAULT_OIDC_USER_DISPLAY_NAME_CLAIM }) ?? + DEFAULT_OIDC_USER_DISPLAY_NAME_CLAIM, OIDC_USER_ROLE_CLAIM: getEnv('OIDC_USER_ROLE_CLAIM', { defaultValue: DEFAULT_OIDC_USER_ROLE_CLAIM }) ?? DEFAULT_OIDC_USER_ROLE_CLAIM, OIDC_ADMIN_ROLE_VALUE: @@ -294,6 +298,11 @@ export interface OIDCConfig { * Optional; defaults to "sub" */ OIDC_USER_REFERENCE_CLAIM: string; + /** Claim used as the display name; e.g. "name" or "preferred_username". + * Optional; defaults to "name". Missing/empty falls back to the user reference. + * Env: `OIDC_USER_DISPLAY_NAME_CLAIM`. + */ + OIDC_USER_DISPLAY_NAME_CLAIM: string; /** Claim to be used as the user role; e.g. "role" or "groups" * Optional; defaults to "groups" */ diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index c3025bb4b..c5638e3f6 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -398,7 +398,7 @@ export interface ScheduleTable { manifest: JSONColumnType; /** `paused` stops triggering and drops the pending run; in-flight runs continue */ status: ScheduleStatus; - /** Identity every run of this schedule executes as (`UserContext.userRef`) */ + /** Identity every run of this schedule executes as (`RequestContext.subject.id`) */ created_by: string; created_at: Date; updated_at: Date; @@ -419,7 +419,7 @@ export interface ScheduleRunTable { scheduled_for: Date; /** `scheduled` | `triggered` | `failed` | `missed` — varchar(16) */ status: ScheduleRunStatus; - /** `UserContext.userRef` of who triggered the run */ + /** `RequestContext.subject.id` of who triggered the run */ triggered_by: string; triggered_at: Date | null; created_at: Date; @@ -453,7 +453,7 @@ export interface McpServerTable { /** * PRIMARY KEY (oauth_server_id, user_id) * No `tenant_id` — already scoped to tenant via the FK. Tokens are per harness user - * (`user_id` = `UserContext.userRef`); any tenant-scoped read resolves `oauth_server_id` + * (`user_id` = `RequestContext.subject.id`); any tenant-scoped read resolves `oauth_server_id` * through mcp_server (by tenant_id + name) first. */ export interface OAuthTokenTable { diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index 57343d86a..114a8814a 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -243,7 +243,7 @@ export interface ScheduleTable { manifest: JsonbColumn; /** `paused` stops triggering and drops the pending run; in-flight runs continue */ status: ScheduleStatus; - /** Identity every run of this schedule executes as (`UserContext.userRef`) */ + /** Identity every run of this schedule executes as (`RequestContext.subject.id`) */ created_by: string; created_at: string; updated_at: string; @@ -264,7 +264,7 @@ export interface ScheduleRunTable { scheduled_for: string; /** `scheduled` | `triggered` | `failed` | `missed` — length ≤ 16 */ status: ScheduleRunStatus; - /** `UserContext.userRef` of who triggered the run */ + /** `RequestContext.subject.id` of who triggered the run */ triggered_by: string; triggered_at: string | null; created_at: string; @@ -297,7 +297,7 @@ export interface McpServerTable { /** * PRIMARY KEY (oauth_server_id, user_id) * No `tenant_id` — already scoped to tenant via the FK. Tokens are per harness user - * (`user_id` = `UserContext.userRef`); any tenant-scoped read resolves `oauth_server_id` + * (`user_id` = `RequestContext.subject.id`); any tenant-scoped read resolves `oauth_server_id` * through mcp_server (by tenant_id + name) first. */ export interface OAuthTokenTable { diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index b3874a51c..b9fd8aaf8 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -11,6 +11,7 @@ */ import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import { @@ -47,8 +48,10 @@ import type { RedisClientType } from 'redis'; import type { Logger } from 'winston'; import { createServerApp } from './app'; -import { requireAccessToken } from './auth/middleware'; +import { createAuthenticator, TrueforgeMode } from './auth/createAuthenticator'; +import { resolveRequestContext } from './auth/identity'; import { initOidc } from './auth/oidc'; +import { rawTokenFromCredential } from './auth/token'; import { McpCatalog } from './catalog/McpCatalog'; import { ModelCatalog } from './catalog/ModelCatalog'; import { SandboxCatalog } from './catalog/SandboxCatalog'; @@ -93,6 +96,16 @@ interface ServerPersistence { redis: RedisClientType | undefined; } +function requireRequestCredentialToken(c: Context): string { + const credential = resolveRequestContext(c).user_credential; + if (credential === null) { + throw new HTTPException(401, { + message: 'Authentication token required to list or call TrueFoundry models and MCP servers', + }); + } + return rawTokenFromCredential(credential.authorization); +} + /** * Per-request model-provider store resolver. In TrueFoundry mode every request gets a token-bound * store over a shared (mTLS) ServiceFoundry client; otherwise the persistence store is reused as-is. @@ -113,7 +126,7 @@ function buildResolveModelProviderStore(options: { // unavailable there; fall back to the persistence store. return c => c - ? new TrueFoundryModelProviderStore({ client, accessToken: requireAccessToken(c) }) + ? new TrueFoundryModelProviderStore({ client, accessToken: requireRequestCredentialToken(c) }) : options.persistenceStore; } @@ -143,7 +156,7 @@ function buildResolveMcpServerStore(options: { }); return c => c - ? new TrueFoundryMcpServerStore({ client, accessToken: requireAccessToken(c) }) + ? new TrueFoundryMcpServerStore({ client, accessToken: requireRequestCredentialToken(c) }) : withAuthPersistence; } @@ -316,6 +329,22 @@ async function createServerRuntime(persistence: ServerPersistence< } const oidcClient = await initOidc(oidc); + let authenticator; + if (isTrueFoundryModeEnabled(configuration)) { + authenticator = createAuthenticator({ + mode: TrueforgeMode.TrueFoundry, + trueFoundryClient: new TrueFoundryServiceFoundryServerClient({ + serviceFoundryServerUrl: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL, + logger, + tls: { enabled: configuration.TRUEFOUNDRY_MTLS_ENABLED, dir: configuration.TRUEFOUNDRY_MTLS_CERTS_DIR }, + }), + }); + } else if (isOidcConfigured(configuration)) { + authenticator = createAuthenticator({ mode: TrueforgeMode.Oidc }); + } else { + authenticator = createAuthenticator({ mode: TrueforgeMode.Standalone }); + } + // Standalone is one process, so it owns the control loops too. const controller = configuration.STANDALONE ? createController({ @@ -348,6 +377,7 @@ async function createServerRuntime(persistence: ServerPersistence< eventSubscriptions, logger, oidcClient, + authenticator, }); return { activeTurns, app, controller, destroyDb, redis, requestReplyRouter }; diff --git a/packages/trueforge/src/mcp/auth/types.ts b/packages/trueforge/src/mcp/auth/types.ts index 659f35ff4..bc228d338 100644 --- a/packages/trueforge/src/mcp/auth/types.ts +++ b/packages/trueforge/src/mcp/auth/types.ts @@ -16,7 +16,7 @@ export interface OAuthToken { scope: string | null; } -/** Composite key: MCP server row id + harness `UserContext.userRef`. */ +/** Composite key: MCP server row id + harness `RequestContext.subject.id`. */ export interface OAuthTokenKey { id: string; userRef: string; diff --git a/packages/trueforge/src/routes/authRoutes.ts b/packages/trueforge/src/routes/authRoutes.ts index ad6a4ed10..5bb2585cb 100644 --- a/packages/trueforge/src/routes/authRoutes.ts +++ b/packages/trueforge/src/routes/authRoutes.ts @@ -63,19 +63,19 @@ export const meRoute = createRoute({ tags: [OpenApiTag.AUTH], summary: 'Current session', description: - 'Returns the authenticated caller identity. When auth is enabled this requires a valid ' + - '`id_token` cookie or `Authorization: Bearer` ID token (401 otherwise). When auth is disabled, ' + - 'returns the default identity.', + 'Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled ' + + 'this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is ' + + 'disabled, returns the standalone default identity.', 'x-fern-sdk-group-name': ['auth'], 'x-fern-sdk-method-name': 'me', responses: { 200: { content: { 'application/json': { schema: GetMeResponseSchema } }, - description: 'Session type and identity for the current request.', + description: 'Caller identity for the current request.', }, 401: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Auth is enabled and the request has no valid cookie or Bearer ID token.', + description: 'Auth is enabled and the request has no valid cookie or Bearer token.', }, }, }); diff --git a/packages/trueforge/src/schemas/auth.ts b/packages/trueforge/src/schemas/auth.ts index 9574f8ca6..2d354a675 100644 --- a/packages/trueforge/src/schemas/auth.ts +++ b/packages/trueforge/src/schemas/auth.ts @@ -28,16 +28,26 @@ export const OAuthCallbackSuccessSchema = z.object({ success: z.literal(true).describe('Present when the OAuth callback completed without a return_to.'), }); +/** Wire copy of identity `SubjectType` — kept local so OpenAPI uses `@hono/zod-openapi`. */ +const GetMeSubjectTypeSchema = z + .enum(['user', 'virtualaccount']) + .describe('Subject kind: interactive user or virtual account.'); + +export const GetMeSubjectSchema = z + .object({ + id: z.string().describe('Stable subject identifier for the caller.'), + type: GetMeSubjectTypeSchema, + display_name: z.string().describe('Human-readable name for the caller.'), + }) + .openapi('GetMeSubject'); + export const GetMeResponseSchema = z .object({ - type: z - .enum(['default', 'oidc-connected']) - .describe( - 'Session kind: `default` when no valid OIDC session; `oidc-connected` after a successful browser login.', - ), - email: z.string().describe('User email from the ID token when connected; `"default"` when anonymous.'), - role: z.string().describe('Caller role.'), + tenant_id: z.string().describe('Tenant scope for the authenticated caller.'), + subject: GetMeSubjectSchema, + is_admin: z.boolean().describe('Whether the caller has admin privileges.'), }) .openapi('GetMeResponse'); +export type GetMeSubject = z.infer; export type GetMeResponse = z.infer; diff --git a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts index 1f40ca014..bb299dab8 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts @@ -9,9 +9,32 @@ import { createInternalTlsDispatcher, normalizeInternalTlsUrl, type InternalTlsO const INTEGRATIONS_PATH = 'v1/provider-integrations'; const INSTALLATIONS_PATH = 'v1/llm-gateway/installations'; const MCP_SERVERS_PATH = 'v1/mcp'; +const SESSION_PATH = 'v1/session'; const INTEGRATIONS_PAGE_SIZE = 1000; const MCP_SERVERS_PAGE_SIZE = 100; +/** + * Fields required to build RequestContext from ServiceFoundry `GET /v1/session`. + * Wire shape is camelCase (Nest Session + exposed `subject()`). + */ +const SessionSubjectSchema = z.object({ + subjectId: z.string().min(1), + subjectType: z.string().min(1), + subjectDisplayName: z.string().nullable().optional(), + subjectSlug: z.string().nullable().optional(), +}); + +const GetSessionResponseSchema = z.object({ + user: z.object({ + tenantName: z.string().min(1), + roles: z.array(z.string()), + subject: SessionSubjectSchema, + }), +}); + +export type GetSessionResponse = z.infer; + + const ListResponseSchema = z.union([ z.array(z.unknown()), z.object({ @@ -137,6 +160,71 @@ export class TrueFoundryServiceFoundryServerClient { return rows[0]; } + /** + * `GET v1/session` for RequestContext mapping. + * 401/403 propagate; transport / non-auth upstream / parse failures → 502 with `{ cause }`. + */ + async getSession(accessToken: string): Promise { + const url = this.#url(SESSION_PATH); + const startedAt = Date.now(); + let response: Awaited>; + try { + response = await undiciFetch(url, { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${accessToken}`, + }, + ...(this.#dispatcher ? { dispatcher: this.#dispatcher } : {}), + }); + } catch (error) { + this.#logger?.warn('TrueFoundry ServiceFoundry session request failed', { + url: url.href, + durationMs: Date.now() - startedAt, + ...extractErrorLogFields(error), + }); + throw new HTTPException(502, { + message: 'TrueFoundry ServiceFoundry server request failed', + cause: error, + }); + } + this.#logger?.info('TrueFoundry ServiceFoundry session request completed', { + url: url.href, + status: response.status, + durationMs: Date.now() - startedAt, + }); + if (response.status === 401 || response.status === 403) { + throw new HTTPException(response.status, { + message: 'TrueFoundry ServiceFoundry server rejected the request', + }); + } + if (!response.ok) { + const detail = await readServiceFoundryErrorMessage(response); + throw new HTTPException(502, { + message: `TrueFoundry ServiceFoundry server request failed: ${detail ?? `HTTP ${String(response.status)}`}`, + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (error) { + throw new HTTPException(502, { + message: 'TrueFoundry ServiceFoundry session response was not valid JSON', + cause: error, + }); + } + + const parsed = GetSessionResponseSchema.safeParse(payload); + if (!parsed.success) { + throw new HTTPException(502, { + message: 'TrueFoundry ServiceFoundry session response was malformed', + cause: parsed.error, + }); + } + return parsed.data; + } + #parseListResponse(payload: unknown): ListResponse { const parsed = ListResponseSchema.safeParse(payload); if (!parsed.success) { diff --git a/packages/trueforge/tests/unit/apis/auth.test.ts b/packages/trueforge/tests/unit/apis/auth.test.ts index 8768fdd0e..e733c4545 100644 --- a/packages/trueforge/tests/unit/apis/auth.test.ts +++ b/packages/trueforge/tests/unit/apis/auth.test.ts @@ -3,8 +3,11 @@ import { createHash } from 'node:crypto'; import type { Configuration } from 'openid-client'; import winston from 'winston'; import { createAuthRouter } from '../../../src/apis/auth'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { createAuthMiddleware } from '../../../src/auth/authenticator'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; +import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { disableOidcAuth, initOidc } from '../../../src/auth/oidc'; +import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; import configuration from '../../../src/config'; jest.mock('../../../src/config', () => { @@ -13,6 +16,7 @@ jest.mock('../../../src/config', () => { OIDC_CLIENT_ID: 'harness-client', OIDC_CLIENT_SECRET: 'harness-secret', OIDC_USER_REFERENCE_CLAIM: 'sub', + OIDC_USER_DISPLAY_NAME_CLAIM: 'name', OIDC_USER_ROLE_CLAIM: 'groups', OIDC_ADMIN_ROLE_VALUE: 'admin', OIDC_SCOPES: ['openid', 'profile', 'email', 'groups'], @@ -53,13 +57,22 @@ const logger = winston.createLogger({ silent: true }); const ACCESS_TOKEN = 'access-1'; +function createTestAuthRouter(params: { oidcClient: Configuration | undefined }) { + const authenticator = params.oidcClient ? new OidcAuthenticator() : new StandaloneAuthenticator(); + return createAuthRouter({ + oidcClient: params.oidcClient, + logger, + authMiddleware: createAuthMiddleware(authenticator), + }); +} + describe('auth router (no identity provider configured)', () => { beforeEach(() => { disableOidcAuth(); }); it('GET /auth/login redirects home — there is nothing to log into', async () => { - const router = createAuthRouter({ oidcClient: undefined, logger }); + const router = createTestAuthRouter({ oidcClient: undefined }); const res = await router.request('/login', { redirect: 'manual' }); @@ -68,7 +81,7 @@ describe('auth router (no identity provider configured)', () => { }); it('GET /auth/callback redirects home — there is nothing to complete', async () => { - const router = createAuthRouter({ oidcClient: undefined, logger }); + const router = createTestAuthRouter({ oidcClient: undefined }); const res = await router.request('/callback?state=abc', { redirect: 'manual' }); @@ -77,23 +90,23 @@ describe('auth router (no identity provider configured)', () => { }); it('POST /auth/logout is a no-op 204 — there is no real session to clear', async () => { - const router = createAuthRouter({ oidcClient: undefined, logger }); + const router = createTestAuthRouter({ oidcClient: undefined }); const res = await router.request('/logout', { method: 'POST' }); expect(res.status).toBe(204); }); - it('GET /auth/me returns the default identity when auth is disabled', async () => { - const router = createAuthRouter({ oidcClient: undefined, logger }); + it('GET /auth/me returns the standalone identity when auth is disabled', async () => { + const router = createTestAuthRouter({ oidcClient: undefined }); const res = await router.request('/me'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ - type: 'default', - email: LOCAL_USER_CONTEXT.userRef, - role: LOCAL_USER_CONTEXT.role, + tenant_id: STANDALONE_REQUEST_CONTEXT.tenant_id, + subject: STANDALONE_REQUEST_CONTEXT.subject, + is_admin: STANDALONE_REQUEST_CONTEXT.is_admin, }); }); }); @@ -202,7 +215,7 @@ describe('auth router (auth enabled)', () => { } it('GET /login redirects to the IdP and stores state', async () => { - const res = await createAuthRouter({ oidcClient, logger }).request('/login?return_to=/sessions/abc123', { + const res = await createTestAuthRouter({ oidcClient }).request('/login?return_to=/sessions/abc123', { redirect: 'manual', }); @@ -217,7 +230,7 @@ describe('auth router (auth enabled)', () => { expect.arrayContaining(['openid', 'profile', 'email', 'groups']), ); expect(JSON.parse(authUrl.searchParams.get('claims') ?? '{}')).toEqual({ - id_token: { sub: { essential: true }, groups: { essential: true } }, + id_token: { sub: { essential: true }, groups: { essential: true }, name: { essential: true } }, }); expect(cookieValue(setCookies(res), STATE_COOKIE)).toBeTruthy(); }); @@ -232,7 +245,7 @@ describe('auth router (auth enabled)', () => { throw new Error('OIDC client was not initialized'); } - const res = await createAuthRouter({ oidcClient: rolesOidcClient, logger }).request('/login', { + const res = await createTestAuthRouter({ oidcClient: rolesOidcClient }).request('/login', { redirect: 'manual', }); @@ -243,13 +256,13 @@ describe('auth router (auth enabled)', () => { ); expect(authUrl.searchParams.get('scope')?.split(' ')).not.toContain('groups'); expect(JSON.parse(authUrl.searchParams.get('claims') ?? '{}')).toEqual({ - id_token: { sub: { essential: true }, roles: { essential: true } }, + id_token: { sub: { essential: true }, roles: { essential: true }, name: { essential: true } }, }); }); // `iss` is forwarded verbatim: IdPs advertising it reject an exchange that drops it. it('GET /callback exchanges the code and sets id_token', async () => { - const router = createAuthRouter({ oidcClient, logger }); + const router = createTestAuthRouter({ oidcClient }); const loginRes = await router.request('/login?return_to=/sessions/abc123', { redirect: 'manual' }); const stateCookieRaw = cookieValue(setCookies(loginRes), STATE_COOKIE) ?? ''; const authorizationUrl = new URL(loginRes.headers.get('location') ?? ''); @@ -275,13 +288,18 @@ describe('auth router (auth enabled)', () => { throw new Error('OIDC client was not initialized'); } - const router = createAuthRouter({ oidcClient: restrictedClient, logger }); + const router = createTestAuthRouter({ oidcClient: restrictedClient }); const loginRes = await router.request('/login', { redirect: 'manual' }); const stateCookieRaw = cookieValue(setCookies(loginRes), STATE_COOKIE) ?? ''; const authorizationUrl = new URL(loginRes.headers.get('location') ?? ''); const state = authorizationUrl.searchParams.get('state') ?? ''; expect(JSON.parse(authorizationUrl.searchParams.get('claims') ?? '{}')).toEqual({ - id_token: { sub: { essential: true }, groups: { essential: true }, email: { essential: true } }, + id_token: { + sub: { essential: true }, + groups: { essential: true }, + name: { essential: true }, + email: { essential: true }, + }, }); const callbackRes = await router.request(`/callback?code=abc123&state=${state}&iss=${encodeURIComponent(ISSUER)}`, { @@ -295,7 +313,7 @@ describe('auth router (auth enabled)', () => { }); it('GET /callback redirects home with error when the IdP returns an error', async () => { - const res = await createAuthRouter({ oidcClient, logger }).request( + const res = await createTestAuthRouter({ oidcClient }).request( '/callback?state=any&error=access_denied&error_description=user%20cancelled', { redirect: 'manual' }, ); @@ -304,7 +322,7 @@ describe('auth router (auth enabled)', () => { }); it('GET /callback uses login_failed when the IdP error has no description', async () => { - const res = await createAuthRouter({ oidcClient, logger }).request('/callback?state=any&error=access_denied', { + const res = await createTestAuthRouter({ oidcClient }).request('/callback?state=any&error=access_denied', { redirect: 'manual', }); expect(res.status).toBe(302); @@ -312,7 +330,7 @@ describe('auth router (auth enabled)', () => { }); it('GET /callback uses login_failed when the IdP error description is blank', async () => { - const res = await createAuthRouter({ oidcClient, logger }).request( + const res = await createTestAuthRouter({ oidcClient }).request( '/callback?state=any&error=access_denied&error_description=%20%20', { redirect: 'manual' }, ); @@ -324,7 +342,7 @@ describe('auth router (auth enabled)', () => { // No `error` code → this is our own validation failure, so the attacker-supplied // description must not be reflected; the reason stays generic. const crafted = 'Your%20account%20is%20compromised%2C%20call%201-800-EVIL'; - const res = await createAuthRouter({ oidcClient, logger }).request( + const res = await createTestAuthRouter({ oidcClient }).request( `/callback?state=any&error_description=${crafted}`, { redirect: 'manual' }, ); @@ -333,7 +351,7 @@ describe('auth router (auth enabled)', () => { }); it('GET /callback redirects home with error when state mismatches', async () => { - const res = await createAuthRouter({ oidcClient, logger }).request('/callback?code=abc&state=wrong', { + const res = await createTestAuthRouter({ oidcClient }).request('/callback?code=abc&state=wrong', { redirect: 'manual', }); expect(res.status).toBe(302); @@ -342,7 +360,7 @@ describe('auth router (auth enabled)', () => { it('GET /callback replays home when already authenticated and the state cookie is spent', async () => { const token = await createIdToken(); - const res = await createAuthRouter({ oidcClient, logger }).request('/callback?code=abc&state=spent', { + const res = await createTestAuthRouter({ oidcClient }).request('/callback?code=abc&state=spent', { redirect: 'manual', headers: { Cookie: `${ID_TOKEN_COOKIE}=${token}` }, }); @@ -362,7 +380,7 @@ describe('auth router (auth enabled)', () => { } const token = await createIdToken(); - const res = await createAuthRouter({ oidcClient: restrictedClient, logger }).request( + const res = await createTestAuthRouter({ oidcClient: restrictedClient }).request( '/callback?code=abc&state=spent', { redirect: 'manual', @@ -378,7 +396,7 @@ describe('auth router (auth enabled)', () => { it('GET /callback replays home when already authenticated even if the IdP returned an error', async () => { const token = await createIdToken(); - const res = await createAuthRouter({ oidcClient, logger }).request( + const res = await createTestAuthRouter({ oidcClient }).request( '/callback?state=any&error=access_denied&error_description=user%20cancelled', { redirect: 'manual', @@ -399,7 +417,7 @@ describe('auth router (auth enabled)', () => { } const token = await createIdToken(); - const router = createAuthRouter({ oidcClient: restrictedClient, logger }); + const router = createTestAuthRouter({ oidcClient: restrictedClient }); const loginRes = await router.request('/login?return_to=/sessions/abc123', { redirect: 'manual' }); const stateCookieRaw = cookieValue(setCookies(loginRes), STATE_COOKIE) ?? ''; const authorizationUrl = new URL(loginRes.headers.get('location') ?? ''); @@ -418,7 +436,7 @@ describe('auth router (auth enabled)', () => { it('GET /callback keeps the existing session when code exchange fails', async () => { const token = await createIdToken(); - const router = createAuthRouter({ oidcClient, logger }); + const router = createTestAuthRouter({ oidcClient }); const loginRes = await router.request('/login?return_to=/sessions/abc123', { redirect: 'manual' }); const stateCookieRaw = cookieValue(setCookies(loginRes), STATE_COOKIE) ?? ''; const authorizationUrl = new URL(loginRes.headers.get('location') ?? ''); @@ -442,7 +460,7 @@ describe('auth router (auth enabled)', () => { }); it('POST /logout clears id_token even when no cookie is present', async () => { - const res = await createAuthRouter({ oidcClient, logger }).request('/logout', { + const res = await createTestAuthRouter({ oidcClient }).request('/logout', { method: 'POST', }); @@ -453,16 +471,20 @@ describe('auth router (auth enabled)', () => { }); it('GET /me returns 401 when the id_token cookie is missing', async () => { - const res = await createAuthRouter({ oidcClient, logger }).request('/me'); + const res = await createTestAuthRouter({ oidcClient }).request('/me'); expect(res.status).toBe(401); }); - it('GET /me returns oidc-connected identity when authenticated', async () => { + it('GET /me returns RequestContext identity when authenticated', async () => { const token = await createIdToken(); - const res = await createAuthRouter({ oidcClient, logger }).request('/me', { + const res = await createTestAuthRouter({ oidcClient }).request('/me', { headers: { Cookie: `${ID_TOKEN_COOKIE}=${token}` }, }); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ type: 'oidc-connected', email: 'user-1', role: 'user' }); + expect(await res.json()).toEqual({ + tenant_id: 'default', + subject: { id: 'user-1', type: 'user', display_name: 'user-1' }, + is_admin: false, + }); }); }); diff --git a/packages/trueforge/tests/unit/apis/capabilities.test.ts b/packages/trueforge/tests/unit/apis/capabilities.test.ts index fc9c09047..a6e135f2b 100644 --- a/packages/trueforge/tests/unit/apis/capabilities.test.ts +++ b/packages/trueforge/tests/unit/apis/capabilities.test.ts @@ -6,8 +6,12 @@ import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import type { Configuration } from 'openid-client'; import { createLogger } from 'winston'; import { createCapabilitiesRouter } from '../../../src/apis/capabilities'; -import { authMiddleware } from '../../../src/auth/middleware'; +import { createAuthMiddleware } from '../../../src/auth/authenticator'; +import type { Authenticator } from '../../../src/auth/authenticator'; +import { resolveRequestContext } from '../../../src/auth/identity'; +import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { disableOidcAuth, enableOidcAuth, initOidc } from '../../../src/auth/oidc'; +import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; import type { OIDCConfig } from '../../../src/config'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { createSqliteDb } from '../../../src/db/sqlite/client'; @@ -33,6 +37,7 @@ const OIDC_CONFIG: OIDCConfig = { OIDC_CLIENT_ID: AUDIENCE, OIDC_CLIENT_SECRET: 'harness-secret', OIDC_USER_REFERENCE_CLAIM: 'sub', + OIDC_USER_DISPLAY_NAME_CLAIM: 'name', OIDC_USER_ROLE_CLAIM: 'groups', OIDC_ADMIN_ROLE_VALUE: 'admin', OIDC_SCOPES: ['openid', 'profile', 'email', 'groups'], @@ -46,9 +51,9 @@ function json(body: unknown, status = 200): Response { }); } -function withAuth(router: OpenAPIHono): OpenAPIHono { +function withAuth(router: OpenAPIHono, authenticator: Authenticator): OpenAPIHono { const shell = new OpenAPIHono(); - shell.use('*', authMiddleware); + shell.use('*', createAuthMiddleware(authenticator)); shell.route('/', router); return shell; } @@ -64,14 +69,16 @@ describe('capabilities routers', () => { setCachedLocalSandboxSupport(undefined); }); - function makeRouter(): OpenAPIHono { + function makeRouter(authenticator: Authenticator = new StandaloneAuthenticator()): OpenAPIHono { const db = createSqliteDb(':memory:'); return withAuth( createCapabilitiesRouter({ sandboxProviderStore: new SqliteSandboxProviderStore(db), withTransaction: callback => db.transaction().execute(callback), logger: silentLogger, + resolveRequestContext, }), + authenticator, ); } @@ -242,7 +249,9 @@ describe('capabilities routers', () => { sandboxProviderStore: new SqliteSandboxProviderStore(db), withTransaction: callback => db.transaction().execute(callback), logger: silentLogger, + resolveRequestContext, }), + new OidcAuthenticator(), ); const adminRes = await router.request('/', { diff --git a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts index 6212a27ae..66ea90872 100644 --- a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts +++ b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts @@ -3,9 +3,9 @@ import { AgentSpecSchema, Sessions } from '@truefoundry/trueforge-core/agent-ses import { RequestReplyRouter } from '@truefoundry/trueforge-core/request-reply'; import { createClient } from 'redis'; import { createLogger } from 'winston'; -import { createSessionsRouter, TENANT_ID } from '../../../src/apis/sessions'; +import { createSessionsRouter } from '../../../src/apis/sessions'; import { createTurnsRouter } from '../../../src/apis/turns'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; @@ -52,7 +52,7 @@ describe('public CRUD after session deletion', () => { sandboxProviderStore, redis: createClient(), requestReplyRouter: new RequestReplyRouter(), - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, logger: createLogger({ silent: true }), }), ); @@ -70,14 +70,14 @@ describe('public CRUD after session deletion', () => { eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore, logger: createLogger({ silent: true }), - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), ); await sessionStore.createSession({ - tenant_id: TENANT_ID, + tenant_id: 'default', session_id: 's1', - created_by: LOCAL_USER_CONTEXT.userRef, + created_by: STANDALONE_REQUEST_CONTEXT.subject.id, agent: { type: 'inline', spec: AgentSpecSchema.parse({ diff --git a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts index a38a29430..a02d369c5 100644 --- a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts @@ -5,7 +5,7 @@ import winston from 'winston'; import { createMcpOAuthRouter } from '../../../src/apis/mcpOAuth'; import { createMcpServersRouter, createSettingsMcpServersRouter } from '../../../src/apis/mcpServers'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import configuration from '../../../src/config'; import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import type { IMcpServerWithAuthStore } from '../../../src/db/mcpServerStore'; @@ -99,14 +99,14 @@ describe('MCP OAuth authorize + callback', () => { tokenStore, withTransaction, logger, - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }); mcpServersRouter = createMcpServersRouter({ resolveMcpServerStore: () => mcpServerStore, tokenStore, withTransaction, logger, - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }); oauthRouter = createMcpOAuthRouter({ tokenStore, @@ -186,7 +186,7 @@ describe('MCP OAuth authorize + callback', () => { const record = await mcpServerStore.getServer({ tenant_id: 'default', name: 'oauth-mcp' }); expect(record).toBeDefined(); - const token = await tokenStore.getToken({ id: record?.id ?? '', userRef: LOCAL_USER_CONTEXT.userRef }); + const token = await tokenStore.getToken({ id: record?.id ?? '', userRef: STANDALONE_REQUEST_CONTEXT.subject.id }); expect(token?.accessToken).toBe('access-1'); expect(token?.refreshToken).toBe('refresh-1'); @@ -233,7 +233,12 @@ describe('MCP OAuth authorize + callback', () => { tokenStore, withTransaction, logger, - resolveUserContext: () => ({ userRef: 'other-user', role: 'user' }), + resolveRequestContext: () => ({ + tenant_id: 'default', + subject: { id: 'other-user', type: 'user', display_name: 'other-user' }, + is_admin: false, + user_credential: null, + }), }); const put = await settingsRouter.request('/', { diff --git a/packages/trueforge/tests/unit/apis/mcpServers.test.ts b/packages/trueforge/tests/unit/apis/mcpServers.test.ts index 242b7fdfe..c6c1dcb4d 100644 --- a/packages/trueforge/tests/unit/apis/mcpServers.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpServers.test.ts @@ -1,8 +1,7 @@ import winston from 'winston'; import { createCatalogRouter } from '../../../src/apis/catalog'; import { createMcpServersRouter, createSettingsMcpServersRouter } from '../../../src/apis/mcpServers'; -import { TENANT_ID } from '../../../src/apis/sessions'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpCatalog } from '../../../src/catalog/McpCatalog'; import { ModelCatalog } from '../../../src/catalog/ModelCatalog'; import { SandboxCatalog } from '../../../src/catalog/SandboxCatalog'; @@ -113,7 +112,7 @@ describe('mcp-servers routers', () => { tokenStore, withTransaction, logger, - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }); catalogRouter = createCatalogRouter({ modelCatalog: ModelCatalog.load(), @@ -126,14 +125,14 @@ describe('mcp-servers routers', () => { tokenStore, withTransaction, logger, - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }); }); /** Persist a DCR server + stub client without calling the authorization server. */ async function seedDcrServerWithClient(body: typeof putBodyWithDcr = putBodyWithDcr) { const record = await mcpServerStore.upsertServer({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: body.name, manifest: body, }); @@ -225,7 +224,7 @@ describe('mcp-servers routers', () => { message: "Failed to discover OAuth authorization server for MCP server 'linear'", }, }); - expect(await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: putBodyWithDcr.name })).toBeUndefined(); + expect(await mcpServerStore.getServer({ tenant_id: 'default', name: putBodyWithDcr.name })).toBeUndefined(); }); it('POST with DCR fails registration without writing a server row', async () => { @@ -238,7 +237,7 @@ describe('mcp-servers routers', () => { }; const response = await settingsRouter.request('/', postInit(wrapManifest(createDcr))); expect(response.status).toBe(422); - expect(await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: createDcr.name })).toBeUndefined(); + expect(await mcpServerStore.getServer({ tenant_id: 'default', name: createDcr.name })).toBeUndefined(); }); it('DCR server reads authenticated when a token row exists, auth_required once deleted', async () => { @@ -246,7 +245,7 @@ describe('mcp-servers routers', () => { await tokenStore.saveToken({ id: record.id, - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, token: { accessToken: 'access-1', refreshToken: 'refresh-1', @@ -264,7 +263,7 @@ describe('mcp-servers routers', () => { // List auth_status is presence-based; expiry is handled at resolve/refresh time. await tokenStore.saveToken({ id: record.id, - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, token: { accessToken: 'access-1', refreshToken: 'refresh-1', @@ -279,7 +278,7 @@ describe('mcp-servers routers', () => { status: 'authenticated', }); - await tokenStore.deleteToken({ id: record.id, userRef: LOCAL_USER_CONTEXT.userRef }); + await tokenStore.deleteToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id }); const cleared = await settingsRouter.request('/'); const clearedBody = (await cleared.json()) as { data: { name: string; auth_status: { status: string } }[] }; @@ -294,7 +293,7 @@ describe('mcp-servers routers', () => { await tokenStore.saveToken({ id: record.id, - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, token: { accessToken: 'access-1', refreshToken: 'refresh-1', @@ -310,14 +309,14 @@ describe('mcp-servers routers', () => { data: configured(putBodyWithDcr, 'authenticated'), }); - await tokenStore.deleteToken({ id: record.id, userRef: LOCAL_USER_CONTEXT.userRef }); + await tokenStore.deleteToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id }); }); it('PUT URL change re-registers DCR and clears tokens and pending authorizations', async () => { const record = await seedDcrServerWithClient(); await tokenStore.saveToken({ id: record.id, - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, token: { accessToken: 'stale-for-old-url', refreshToken: 'stale-refresh', @@ -338,7 +337,7 @@ describe('mcp-servers routers', () => { await tokenStore.savePendingAuthorization({ state: 'stale-pending-state', id: record.id, - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, mcpServerUrl: putBodyWithDcr.url, codeVerifier: 'stale-verifier', returnTo: null, @@ -401,7 +400,7 @@ describe('mcp-servers routers', () => { expect(await response.json()).toEqual({ data: configured({ ...putBodyWithDcr, url: newUrl }, 'auth_required'), }); - expect(await tokenStore.getToken({ id: record.id, userRef: LOCAL_USER_CONTEXT.userRef })).toBeUndefined(); + expect(await tokenStore.getToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id })).toBeUndefined(); expect(await tokenStore.getToken({ id: record.id, userRef: 'other-user' })).toBeUndefined(); expect(await tokenStore.consumePendingAuthorization({ state: 'stale-pending-state' })).toBeUndefined(); expect(await mcpServerStore.getClient({ id: record.id })).toMatchObject({ @@ -411,7 +410,7 @@ describe('mcp-servers routers', () => { globalThis.fetch = realFetch; // Restore baseline for later suite cases that still expect original linear URL / no token. await mcpServerStore.upsertServer({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: putBodyWithDcr.name, manifest: putBodyWithDcr, }); @@ -439,7 +438,7 @@ describe('mcp-servers routers', () => { data: configured(putBodyWithHeaderAuthWire, 'authenticated'), }); - const stored = await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: putBodyWithHeaderAuth.name }); + const stored = await mcpServerStore.getServer({ tenant_id: 'default', name: putBodyWithHeaderAuth.name }); expect(stored?.manifest.auth).toEqual(putBodyWithHeaderAuth.auth); }); @@ -483,7 +482,7 @@ describe('mcp-servers routers', () => { ), }); - const stored = await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: putBodyWithHeaderAuth.name }); + const stored = await mcpServerStore.getServer({ tenant_id: 'default', name: putBodyWithHeaderAuth.name }); expect(stored?.manifest).toEqual({ ...putBodyWithHeaderAuth, url: keep.url, @@ -518,7 +517,7 @@ describe('mcp-servers routers', () => { ), }); - const stored = await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: putBodyWithHeaderAuth.name }); + const stored = await mcpServerStore.getServer({ tenant_id: 'default', name: putBodyWithHeaderAuth.name }); expect(stored?.manifest).toEqual({ ...putBodyWithHeaderAuth, url: keep.url, @@ -553,7 +552,7 @@ describe('mcp-servers routers', () => { ), }); - const stored = await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: putBodyWithHeaderAuth.name }); + const stored = await mcpServerStore.getServer({ tenant_id: 'default', name: putBodyWithHeaderAuth.name }); expect(stored?.manifest.auth).toEqual(rotated.auth); }); @@ -590,11 +589,11 @@ describe('mcp-servers routers', () => { }); it('GET / chat list auth_status is scoped to the calling user', async () => { - const record = await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: putBodyWithDcr.name }); + const record = await mcpServerStore.getServer({ tenant_id: 'default', name: putBodyWithDcr.name }); if (record === undefined) throw new Error('expected DCR server to exist'); await tokenStore.saveToken({ id: record.id, - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, token: { accessToken: 'access-1', refreshToken: 'refresh-1', @@ -616,7 +615,12 @@ describe('mcp-servers routers', () => { tokenStore, withTransaction, logger, - resolveUserContext: () => ({ userRef: 'other-user', role: 'user' }), + resolveRequestContext: () => ({ + tenant_id: 'default', + subject: { id: 'other-user', type: 'user', display_name: 'other-user' }, + is_admin: false, + user_credential: null, + }), }); const forOther = await otherRouter.request('/'); const otherBody = (await forOther.json()) as { @@ -626,7 +630,7 @@ describe('mcp-servers routers', () => { status: 'auth_required', }); - await tokenStore.deleteToken({ id: record.id, userRef: LOCAL_USER_CONTEXT.userRef }); + await tokenStore.deleteToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id }); }); it('PUT rejects invalid bodies at the Zod layer', async () => { @@ -731,7 +735,7 @@ describe('mcp-servers routers', () => { ); // Registration fails before any DB write — no server exists to authorize. expect(put.status).toBe(422); - expect(await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: 'failing-oauth-mcp' })).toBeUndefined(); + expect(await mcpServerStore.getServer({ tenant_id: 'default', name: 'failing-oauth-mcp' })).toBeUndefined(); const authorize = await mcpServersRouter.request('/failing-oauth-mcp/authorize'); expect(authorize.status).toBe(404); @@ -831,7 +835,7 @@ describe('mcp-servers routers', () => { }); await tokenStore.saveToken({ id: record.id, - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, token: { accessToken: 'access-1', refreshToken: 'refresh-1', @@ -845,7 +849,7 @@ describe('mcp-servers routers', () => { expect(await response.json()).toEqual({ data: configured(putBodyWithDcr, 'auth_required'), }); - expect(await tokenStore.getToken({ id: record.id, userRef: LOCAL_USER_CONTEXT.userRef })).toBeUndefined(); + expect(await tokenStore.getToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id })).toBeUndefined(); expect(await mcpServerStore.getClient({ id: record.id })).toEqual({ server: { authorizationEndpoint: 'https://auth.example.com/authorize', diff --git a/packages/trueforge/tests/unit/apis/modelProviders.test.ts b/packages/trueforge/tests/unit/apis/modelProviders.test.ts index 9ea68b4b2..053bde6f0 100644 --- a/packages/trueforge/tests/unit/apis/modelProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/modelProviders.test.ts @@ -1,9 +1,8 @@ import winston from 'winston'; import { createCatalogRouter } from '../../../src/apis/catalog'; import { createModelsRouter } from '../../../src/apis/models'; -import { TENANT_ID } from '../../../src/apis/sessions'; import { createSettingsRouter } from '../../../src/apis/settings'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpCatalog } from '../../../src/catalog/McpCatalog'; import { ModelCatalog } from '../../../src/catalog/ModelCatalog'; import { SandboxCatalog } from '../../../src/catalog/SandboxCatalog'; @@ -109,7 +108,7 @@ async function createRouters(): Promise<{ sandboxProviderStore: new SqliteSandboxProviderStore(db), withTransaction: callback => db.transaction().execute(callback), logger: winston.createLogger({ silent: true }), - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), catalogRouter: createCatalogRouter({ modelCatalog: ModelCatalog.load(), @@ -120,6 +119,7 @@ async function createRouters(): Promise<{ modelsRouter: createModelsRouter({ resolveModelProviderStore: () => modelProviderStore, withTransaction: callback => db.transaction().execute(callback), + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), modelProviderStore, }; @@ -389,7 +389,7 @@ describe('model-provider secret redaction and strict PUT', () => { expect(updateBody.data.manifest.auth.api_key).toBe(toRedactedSecretValue(anthropicBody.auth.api_key)); expect(updateBody.data.manifest.models).toHaveLength(2); - const stored = await modelProviderStore.getProvider({ tenant_id: TENANT_ID, name: 'anthropic' }); + const stored = await modelProviderStore.getProvider({ tenant_id: 'default', name: 'anthropic' }); if (!stored || !('auth' in stored.manifest)) { throw new Error('expected stored anthropic provider with auth'); } @@ -408,7 +408,7 @@ describe('model-provider secret redaction and strict PUT', () => { data: configured('anthropic', withRedactedApiKey({ ...anthropicProvider, auth: { api_key: rotatedKey } })), }); - const stored = await modelProviderStore.getProvider({ tenant_id: TENANT_ID, name: 'anthropic' }); + const stored = await modelProviderStore.getProvider({ tenant_id: 'default', name: 'anthropic' }); if (!stored || !('auth' in stored.manifest)) { throw new Error('expected stored anthropic provider with auth'); } diff --git a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts index 1ace8b8ef..635742a92 100644 --- a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts @@ -2,9 +2,8 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; import { AgentSpecSchema, Sessions } from '@truefoundry/trueforge-core/agent-session'; import { createLogger } from 'winston'; -import { TENANT_ID } from '../../../src/apis/sessions'; import { createTurnsRouter, toContentDisposition } from '../../../src/apis/turns'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; @@ -53,7 +52,7 @@ async function buildApp() { eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), logger: createLogger({ silent: true }), - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), ); @@ -116,9 +115,9 @@ describe('GET /{session_id}/turns/{turn_id}/download-sandbox-file', () => { it('returns 404 for a turn that does not exist in the session', async () => { const { app, sessions } = await buildApp(); const session = await sessions.create({ - tenant_id: TENANT_ID, + tenant_id: 'default', session_id: 'no-turn', - created_by: LOCAL_USER_CONTEXT.userRef, + created_by: STANDALONE_REQUEST_CONTEXT.subject.id, agent: { type: 'inline', spec: agentSpec() }, external_id: null, }); diff --git a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts index 8f86cd883..6d311f591 100644 --- a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts @@ -12,7 +12,7 @@ import type { SandboxBuild } from '@truefoundry/trueforge-core/core'; import { createLogger } from 'winston'; import { createCatalogRouter } from '../../../src/apis/catalog'; import { createSandboxProvidersRouter } from '../../../src/apis/sandboxProviders'; -import { TENANT_ID } from '../../../src/apis/sessions'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpCatalog } from '../../../src/catalog/McpCatalog'; import { ModelCatalog } from '../../../src/catalog/ModelCatalog'; import { SandboxCatalog } from '../../../src/catalog/SandboxCatalog'; @@ -89,6 +89,7 @@ async function createRouters(): Promise<{ sandboxProviderStore, withTransaction: callback => db.transaction().execute(callback), logger: silentLogger, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), sandboxProviderStore, }; @@ -115,6 +116,7 @@ describe('sandboxProviders router', () => { sandboxProviderStore, withTransaction: callback => db.transaction().execute(callback), logger: silentLogger, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }); catalogRouter = createCatalogRouter({ modelCatalog: ModelCatalog.load(), @@ -145,7 +147,7 @@ describe('sandboxProviders router', () => { expect(get.status).toBe(200); expect(await get.json()).toEqual({ data: putBodyWire }); - const stored = await sandboxProviderStore.getSandboxProvider(TENANT_ID); + const stored = await sandboxProviderStore.getSandboxProvider('default'); expect(stored?.manifest).toEqual(putBody); }); @@ -229,7 +231,7 @@ describe('sandbox-provider secret redaction and strict PUT', () => { expect(update.status).toBe(200); expect(await update.json()).toEqual({ data: wireResponse(redactedKeep) }); - const stored = await sandboxProviderStore.getSandboxProvider(TENANT_ID); + const stored = await sandboxProviderStore.getSandboxProvider('default'); expect(stored?.manifest).toEqual({ ...putBody, exec_timeout_ms: 120000 }); }); @@ -247,7 +249,7 @@ describe('sandbox-provider secret redaction and strict PUT', () => { data: wireResponse({ ...keep, auth: { api_key: toRedactedSecretValue(putBody.auth.api_key) } }), }); - const stored = await sandboxProviderStore.getSandboxProvider(TENANT_ID); + const stored = await sandboxProviderStore.getSandboxProvider('default'); expect(stored?.manifest).toEqual(putBody); }); @@ -263,7 +265,7 @@ describe('sandbox-provider secret redaction and strict PUT', () => { data: wireResponse({ ...rotated, auth: { api_key: toRedactedSecretValue(rotatedKey) } }), }); - const stored = await sandboxProviderStore.getSandboxProvider(TENANT_ID); + const stored = await sandboxProviderStore.getSandboxProvider('default'); expect(stored?.manifest.auth.api_key).toBe(rotatedKey); }); diff --git a/packages/trueforge/tests/unit/apis/schedules.test.ts b/packages/trueforge/tests/unit/apis/schedules.test.ts index b98b088ed..0900f9933 100644 --- a/packages/trueforge/tests/unit/apis/schedules.test.ts +++ b/packages/trueforge/tests/unit/apis/schedules.test.ts @@ -1,8 +1,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; import { createSchedulesRouter } from '../../../src/apis/schedules'; -import { TENANT_ID } from '../../../src/apis/sessions'; -import type { UserContext } from '../../../src/auth/identity'; +import type { RequestContext } from '../../../src/auth/identity'; import { ScheduleAgentNotFoundError, startScheduleRun } from '../../../src/controller/scheduleDispatch'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; @@ -23,9 +22,24 @@ jest.mock('../../../src/controller/scheduleDispatch', () => ({ const mockedStartScheduleRun = jest.mocked(startScheduleRun); -const ALICE: UserContext = { userRef: 'alice', role: 'user' }; -const BOB: UserContext = { userRef: 'bob', role: 'user' }; -const ADMIN: UserContext = { userRef: 'root', role: 'admin' }; +const ALICE: RequestContext = { + tenant_id: 'default', + subject: { id: 'alice', type: 'user', display_name: 'alice' }, + is_admin: false, + user_credential: null, +}; +const BOB: RequestContext = { + tenant_id: 'default', + subject: { id: 'bob', type: 'user', display_name: 'bob' }, + is_admin: false, + user_credential: null, +}; +const ADMIN: RequestContext = { + tenant_id: 'default', + subject: { id: 'root', type: 'user', display_name: 'root' }, + is_admin: true, + user_credential: null, +}; const scheduleBody = { agent_name: 'reporter', @@ -39,13 +53,13 @@ async function setup() { const agentStore = new SqliteAgentStore(db); const scheduleStore = new SqliteScheduleStore(db); await agentStore.createAgent({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'reporter', manifest: AgentSpecSchema.parse({ model: { name: 'test-provider/test-model' }, instructions: 'test' }), external_id: null, }); - let current: UserContext = ALICE; + let current: RequestContext = ALICE; const app = new OpenAPIHono(); app.route( '/', @@ -67,11 +81,11 @@ async function setup() { logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() } as never, }, withTransaction: callback => db.transaction().execute(callback), - resolveUserContext: () => current, + resolveRequestContext: () => current, }), ); - const asUser = (user: UserContext) => { + const asUser = (user: RequestContext) => { current = user; }; const postJson = (path: string, method: string, body: unknown) => @@ -153,7 +167,7 @@ describe('schedule RBAC — creator-scoped, admin sees all', () => { const { app, asUser, agentStore, postJson } = await setup(); // A second agent so both schedules can share the same name without colliding. await agentStore.createAgent({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'reporter-two', manifest: AgentSpecSchema.parse({ model: { name: 'test-provider/test-model' }, instructions: 'test' }), external_id: null, @@ -189,7 +203,7 @@ describe('schedule list agent_names filter', () => { it('filters by a single agent_names value and by comma-separated agent_names', async () => { const { app, asUser, agentStore, postJson } = await setup(); await agentStore.createAgent({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'reporter-two', manifest: AgentSpecSchema.parse({ model: { name: 'test-provider/test-model' }, instructions: 'test' }), external_id: null, @@ -249,7 +263,7 @@ describe('create schedule run', () => { const created = await postJson('/', 'POST', scheduleBody); const { id: scheduleId } = ((await created.json()) as { data: { id: string } }).data; - const pendingBefore = await scheduleStore.getScheduledRunFor({ tenant_id: TENANT_ID, schedule_id: scheduleId }); + const pendingBefore = await scheduleStore.getScheduledRunFor({ tenant_id: 'default', schedule_id: scheduleId }); expect(pendingBefore?.status).toBe('scheduled'); const res = await postJson('/runs', 'POST', { schedule_id: scheduleId }); @@ -274,11 +288,11 @@ describe('create schedule run', () => { }), ); - const pendingAfter = await scheduleStore.getScheduledRunFor({ tenant_id: TENANT_ID, schedule_id: scheduleId }); + const pendingAfter = await scheduleStore.getScheduledRunFor({ tenant_id: 'default', schedule_id: scheduleId }); expect(pendingAfter?.id).toBe(pendingBefore?.id); expect(pendingAfter?.status).toBe('scheduled'); - const runs = await scheduleStore.listRuns({ tenant_id: TENANT_ID, schedule_id: scheduleId }); + const runs = await scheduleStore.listRuns({ tenant_id: 'default', schedule_id: scheduleId }); expect(runs.map(r => r.status).sort()).toEqual(['scheduled', 'triggered']); }); @@ -309,7 +323,7 @@ describe('create schedule run', () => { expect(res.status).toBe(404); expect(((await res.json()) as { error: { message: string } }).error.message).toBe('Agent not found: reporter'); - const runs = await scheduleStore.listRuns({ tenant_id: TENANT_ID, schedule_id: scheduleId }); + const runs = await scheduleStore.listRuns({ tenant_id: 'default', schedule_id: scheduleId }); const runNow = runs.find(r => r.name.startsWith('manual-')); expect(runNow?.status).toBe('failed'); }); diff --git a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts index 6bebb6c2b..704b9027e 100644 --- a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts @@ -7,10 +7,9 @@ import { createInternalMetricsRouter } from '../../../src/apis/sessionMetrics'; import { createInternalSessionsRouter, createSessionsRouter, - TENANT_ID, type SessionsRouterDeps, } from '../../../src/apis/sessions'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; @@ -58,7 +57,7 @@ describe('sessions HTTP agent binding', () => { agentStore = new SqliteAgentStore(db); await modelProviderStore.upsertProvider({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'anthropic', manifest: { type: 'anthropic', @@ -86,7 +85,7 @@ describe('sessions HTTP agent binding', () => { sandboxProviderStore, redis: createClient(), requestReplyRouter: new RequestReplyRouter(), - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, logger: createLogger({ silent: true }), }; app.route('/', createSessionsRouter(deps)); @@ -95,7 +94,7 @@ describe('sessions HTTP agent binding', () => { '/api/internal/metrics', createInternalMetricsRouter({ sessionMetricsStore, - resolveUserContext: deps.resolveUserContext, + resolveRequestContext: deps.resolveRequestContext, }), ); }); @@ -113,7 +112,7 @@ describe('sessions HTTP agent binding', () => { }; expect(json.data.agent.type).toBe('inline'); expect(json.data.agent.spec.instructions).toBe('inline'); - expect(json.data.created_by).toBe(LOCAL_USER_CONTEXT.userRef); + expect(json.data.created_by).toBe(STANDALONE_REQUEST_CONTEXT.subject.id); expect(json.data.metrics).toEqual({ total_cost_in_usd: 0, total_duration_ms: 0, total_turns: 0 }); }); @@ -124,7 +123,7 @@ describe('sessions HTTP agent binding', () => { it('creates a named session and filters list by agent_id', async () => { const agent = await agentStore.createAgent({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'named-agent', manifest: AgentSpecSchema.parse({ model: { name: 'anthropic/claude-sonnet-4-6' }, @@ -151,22 +150,22 @@ describe('sessions HTTP agent binding', () => { it('returns caller-scoped metrics for a named agent', async () => { const agent = await agentStore.createAgent({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'metrics-agent', manifest: inlineSpec, external_id: null, }); await sessionStore.createSession({ - tenant_id: TENANT_ID, + tenant_id: 'default', session_id: 'my-metrics-session', - created_by: LOCAL_USER_CONTEXT.userRef, + created_by: STANDALONE_REQUEST_CONTEXT.subject.id, agent: { type: 'reference', id: agent.id, name: agent.name }, custom: null, metadata: {}, external_id: null, }); await sessionStore.createSession({ - tenant_id: TENANT_ID, + tenant_id: 'default', session_id: 'other-user-metrics-session', created_by: 'someone-else', agent: { type: 'reference', id: agent.id, name: agent.name }, @@ -228,7 +227,7 @@ describe('sessions HTTP agent binding', () => { it("rejects access to another user's session on get/update/delete/cancel/events and scopes list", async () => { await sessionStore.createSession({ - tenant_id: TENANT_ID, + tenant_id: 'default', session_id: 'other-user-session', created_by: 'someone-else', agent: { type: 'inline', spec: inlineSpec }, @@ -240,13 +239,13 @@ describe('sessions HTTP agent binding', () => { const created = await app.request('/', jsonInit('POST', { agent: { spec: inlineSpec } })); expect(created.status).toBe(201); const json = (await created.json()) as { data: { id: string; created_by: string } }; - expect(json.data.created_by).toBe(LOCAL_USER_CONTEXT.userRef); + expect(json.data.created_by).toBe(STANDALONE_REQUEST_CONTEXT.subject.id); const listed = await app.request('/'); expect(listed.status).toBe(200); const listedJson = (await listed.json()) as { data: Array<{ id: string; created_by: string }> }; expect(listedJson.data.map(row => row.id)).toEqual([json.data.id]); - expect(listedJson.data.every(row => row.created_by === LOCAL_USER_CONTEXT.userRef)).toBe(true); + expect(listedJson.data.every(row => row.created_by === STANDALONE_REQUEST_CONTEXT.subject.id)).toBe(true); const forbiddenBody = { error: { message: 'Only the session creator can access this session' } }; @@ -276,7 +275,7 @@ describe('sessions HTTP agent binding', () => { it('rejects PATCH agent on a named session', async () => { const agent = await agentStore.createAgent({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'named-agent', manifest: AgentSpecSchema.parse({ model: { name: 'anthropic/claude-sonnet-4-6' }, @@ -396,7 +395,7 @@ describe('sessions HTTP agent binding', () => { expect(againJson.data.agent.spec.instructions).toBe('inline'); await sessionStore.createSession({ - tenant_id: TENANT_ID, + tenant_id: 'default', session_id: 'someone-elses-session', created_by: 'someone-else', agent: { type: 'inline', spec: inlineSpec }, diff --git a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts index ee5c8a6c8..5f7c6bd6b 100644 --- a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts +++ b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts @@ -3,7 +3,7 @@ import type { Sessions } from '@truefoundry/trueforge-core/agent-session'; import { AgentHarnessError } from '@truefoundry/trueforge-core/core'; import { createLogger } from 'winston'; import { createTurnsRouter } from '../../../src/apis/turns'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; @@ -43,8 +43,10 @@ async function postTurnRejectingWith(error: AgentHarnessError): Promise Promise.resolve({ + session_id: 's1', + tenant_id: STANDALONE_REQUEST_CONTEXT.tenant_id, agent_spec: { model: { name: 'test-provider/test-model' } }, - record: { last_turn_id: null, created_by: LOCAL_USER_CONTEXT.userRef }, + record: { last_turn_id: null, created_by: STANDALONE_REQUEST_CONTEXT.subject.id }, createTurn: () => Promise.reject(error), }), } as unknown as Sessions; @@ -70,7 +72,7 @@ async function postTurnRejectingWith(error: AgentHarnessError): Promise LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), ); diff --git a/packages/trueforge/tests/unit/apis/turns.test.ts b/packages/trueforge/tests/unit/apis/turns.test.ts index 8128cf964..fc2bfd620 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -7,9 +7,8 @@ import { } from '@truefoundry/trueforge-core/agent-session'; import type { Kysely } from 'kysely'; import { createLogger } from 'winston'; -import { TENANT_ID } from '../../../src/apis/sessions'; import { createTurnsRouter, turnStreamId } from '../../../src/apis/turns'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; @@ -41,7 +40,7 @@ describe('turns', () => { const sessions = new Sessions({ sessionStore }); await sessionStore.createSession({ - tenant_id: TENANT_ID, + tenant_id: 'default', session_id: 's1', created_by: 'someone-else', agent: { @@ -72,7 +71,7 @@ describe('turns', () => { eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), logger: createLogger({ silent: true }), - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), ); @@ -146,8 +145,9 @@ describe('turns', () => { get: () => Promise.resolve({ session_id: 's1', + tenant_id: STANDALONE_REQUEST_CONTEXT.tenant_id, spec: AgentSpecSchema.parse({ model: { name: 'test-provider/test-model' } }), - record: { last_turn_id: null, created_by: LOCAL_USER_CONTEXT.userRef }, + record: { last_turn_id: null, created_by: STANDALONE_REQUEST_CONTEXT.subject.id }, createTurn: () => Promise.resolve({ id: 'turn-non-stream', @@ -192,7 +192,7 @@ describe('turns', () => { eventSubscriptions, sandboxProviderStore: new SqliteSandboxProviderStore(db), logger, - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), ); @@ -218,7 +218,7 @@ describe('turns', () => { // Resumable stream must exist before the JSON response returns. await expect( - eventSubscriptions.get(turnStreamId(TENANT_ID, 's1', 'turn-non-stream')).assertSubscribable(), + eventSubscriptions.get(turnStreamId('default', 's1', 'turn-non-stream')).assertSubscribable(), ).resolves.toBeUndefined(); releaseRest?.(); @@ -260,11 +260,13 @@ describe('turns', () => { const sessions = { get: () => Promise.resolve({ + session_id: 's1', + tenant_id: STANDALONE_REQUEST_CONTEXT.tenant_id, spec: agentSpec, record: { session_id: 's1', last_turn_id: null, - created_by: LOCAL_USER_CONTEXT.userRef, + created_by: STANDALONE_REQUEST_CONTEXT.subject.id, agent: { type: 'inline', spec: agentSpec }, }, createTurn: () => @@ -293,7 +295,7 @@ describe('turns', () => { eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), logger, - resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }), ); diff --git a/packages/trueforge/tests/unit/auth/claims.test.ts b/packages/trueforge/tests/unit/auth/claims.test.ts index 890d84a71..7d0ba838f 100644 --- a/packages/trueforge/tests/unit/auth/claims.test.ts +++ b/packages/trueforge/tests/unit/auth/claims.test.ts @@ -3,7 +3,7 @@ import { claimValues, resolveRole, resolveUserRef, - toUserContext, + toRequestContext, } from '../../../src/auth/claims'; import { EmailNotAllowedError } from '../../../src/auth/emailAllowlist'; import type { OIDCConfig } from '../../../src/config'; @@ -14,6 +14,7 @@ function config(overrides: Partial = {}): OIDCConfig { OIDC_CLIENT_ID: 'client-id', OIDC_CLIENT_SECRET: 'client-secret', OIDC_USER_REFERENCE_CLAIM: 'sub', + OIDC_USER_DISPLAY_NAME_CLAIM: 'name', OIDC_USER_ROLE_CLAIM: 'groups', OIDC_ADMIN_ROLE_VALUE: 'harness-admins', OIDC_SCOPES: ['openid', 'profile', 'email', 'groups'], @@ -22,6 +23,8 @@ function config(overrides: Partial = {}): OIDCConfig { }; } +const TEST_AUTHORIZATION = 'Bearer test'; + describe('claimValues', () => { it('passes through an array of strings', () => { expect(claimValues(['a', 'b'])).toEqual(['a', 'b']); @@ -92,43 +95,85 @@ describe('resolveRole', () => { }); }); -describe('toUserContext', () => { - it('combines userRef and role from the claims', () => { - expect(toUserContext({ sub: 'user-123', groups: ['harness-admins'] }, config())).toEqual({ - userRef: 'user-123', - role: 'admin', +describe('toRequestContext', () => { + it('maps claims onto RequestContext with admin from the role claim', () => { + expect( + toRequestContext({ + claims: { sub: 'user-123', groups: ['harness-admins'], name: 'User One' }, + config: config(), + authorization: TEST_AUTHORIZATION, + }), + ).toEqual({ + tenant_id: 'default', + subject: { id: 'user-123', type: 'user', display_name: 'User One' }, + is_admin: true, + user_credential: { authorization: TEST_AUTHORIZATION }, + }); + }); + + it('falls back display_name to subject id when the display-name claim is absent', () => { + expect( + toRequestContext({ + claims: { sub: 'user-123', groups: ['harness-admins'] }, + config: config(), + authorization: TEST_AUTHORIZATION, + }), + ).toEqual({ + tenant_id: 'default', + subject: { id: 'user-123', type: 'user', display_name: 'user-123' }, + is_admin: true, + user_credential: { authorization: TEST_AUTHORIZATION }, }); }); it('propagates resolveUserRef throwing when the reference claim is missing', () => { - expect(() => toUserContext({ groups: ['harness-admins'] }, config())).toThrow(); + expect(() => + toRequestContext({ + claims: { groups: ['harness-admins'] }, + config: config(), + authorization: TEST_AUTHORIZATION, + }), + ).toThrow(); }); it('allows any email when the allowlist is empty', () => { expect( - toUserContext( - { sub: 'user-123', groups: [], email: 'anyone@elsewhere.com' }, - config({ OIDC_ALLOWED_EMAILS: [] }), - ), - ).toEqual({ userRef: 'user-123', role: 'user' }); + toRequestContext({ + claims: { sub: 'user-123', groups: [], email: 'anyone@elsewhere.com' }, + config: config({ OIDC_ALLOWED_EMAILS: [] }), + authorization: TEST_AUTHORIZATION, + }), + ).toEqual({ + tenant_id: 'default', + subject: { id: 'user-123', type: 'user', display_name: 'user-123' }, + is_admin: false, + user_credential: { authorization: TEST_AUTHORIZATION }, + }); }); it('throws EmailNotAllowedError when the email is outside the allowlist', () => { expect(() => - toUserContext( - { sub: 'user-123', groups: [], email: 'outsider@elsewhere.com' }, - config({ OIDC_ALLOWED_EMAILS: ['*@company.com'] }), - ), + toRequestContext({ + claims: { sub: 'user-123', groups: [], email: 'outsider@elsewhere.com' }, + config: config({ OIDC_ALLOWED_EMAILS: ['*@company.com'] }), + authorization: TEST_AUTHORIZATION, + }), ).toThrow(EmailNotAllowedError); }); it('allows a matching domain glob', () => { expect( - toUserContext( - { sub: 'user-123', groups: ['harness-admins'], email: 'alice@company.com' }, - config({ OIDC_ALLOWED_EMAILS: ['*@company.com'] }), - ), - ).toEqual({ userRef: 'user-123', role: 'admin' }); + toRequestContext({ + claims: { sub: 'user-123', groups: ['harness-admins'], email: 'alice@company.com' }, + config: config({ OIDC_ALLOWED_EMAILS: ['*@company.com'] }), + authorization: TEST_AUTHORIZATION, + }), + ).toEqual({ + tenant_id: 'default', + subject: { id: 'user-123', type: 'user', display_name: 'user-123' }, + is_admin: true, + user_credential: { authorization: TEST_AUTHORIZATION }, + }); }); }); @@ -145,29 +190,38 @@ describe('buildAuthorizationRequestParams', () => { expect(scopes).toEqual(['openid', 'profile', 'email', 'groups']); }); - it('requests both the role claim and the (default) reference claim as essential in the id_token', () => { + it('requests reference, role, and display-name claims as essential in the id_token', () => { const { claims } = buildAuthorizationRequestParams(config({ OIDC_USER_ROLE_CLAIM: 'roles' })); - expect(claims).toEqual({ id_token: { sub: { essential: true }, roles: { essential: true } } }); + expect(claims).toEqual({ + id_token: { sub: { essential: true }, roles: { essential: true }, name: { essential: true } }, + }); }); it('requests a non-default reference claim as essential too', () => { const { claims } = buildAuthorizationRequestParams( config({ OIDC_USER_REFERENCE_CLAIM: 'email', OIDC_USER_ROLE_CLAIM: 'roles' }), ); - expect(claims).toEqual({ id_token: { email: { essential: true }, roles: { essential: true } } }); + expect(claims).toEqual({ + id_token: { email: { essential: true }, roles: { essential: true }, name: { essential: true } }, + }); }); it('collapses to a single essential entry when the reference and role claim names collide', () => { const { claims } = buildAuthorizationRequestParams( config({ OIDC_USER_REFERENCE_CLAIM: 'groups', OIDC_USER_ROLE_CLAIM: 'groups' }), ); - expect(claims).toEqual({ id_token: { groups: { essential: true } } }); + expect(claims).toEqual({ id_token: { groups: { essential: true }, name: { essential: true } } }); }); it('requests email as essential when an allowlist is configured', () => { const { claims } = buildAuthorizationRequestParams(config({ OIDC_ALLOWED_EMAILS: ['*@company.com'] })); expect(claims).toEqual({ - id_token: { sub: { essential: true }, groups: { essential: true }, email: { essential: true } }, + id_token: { + sub: { essential: true }, + groups: { essential: true }, + name: { essential: true }, + email: { essential: true }, + }, }); }); }); diff --git a/packages/trueforge/tests/unit/auth/emailAllowlist.test.ts b/packages/trueforge/tests/unit/auth/emailAllowlist.test.ts index d3ffe5704..3344b418e 100644 --- a/packages/trueforge/tests/unit/auth/emailAllowlist.test.ts +++ b/packages/trueforge/tests/unit/auth/emailAllowlist.test.ts @@ -7,6 +7,7 @@ function config(overrides: Partial = {}): OIDCConfig { OIDC_CLIENT_ID: 'client-id', OIDC_CLIENT_SECRET: 'client-secret', OIDC_USER_REFERENCE_CLAIM: 'sub', + OIDC_USER_DISPLAY_NAME_CLAIM: 'name', OIDC_USER_ROLE_CLAIM: 'groups', OIDC_ADMIN_ROLE_VALUE: 'admin', OIDC_SCOPES: ['openid', 'profile', 'email'], diff --git a/packages/trueforge/tests/unit/auth/identity.test.ts b/packages/trueforge/tests/unit/auth/identity.test.ts index 5eada457d..ff07d2595 100644 --- a/packages/trueforge/tests/unit/auth/identity.test.ts +++ b/packages/trueforge/tests/unit/auth/identity.test.ts @@ -1,50 +1,50 @@ -import { isAdmin, LOCAL_USER_CONTEXT, type UserContext } from '../../../src/auth/identity'; -import { disableOidcAuth, enableOidcAuth, initOidc } from '../../../src/auth/oidc'; -import type { OIDCConfig } from '../../../src/config'; +import { Hono } from 'hono'; +import { resolveRequestContext, STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -const OIDC_CONFIG: OIDCConfig = { - OIDC_ISSUER_URL: 'https://issuer.example.com/', - OIDC_CLIENT_ID: 'harness-client', - OIDC_CLIENT_SECRET: 'harness-secret', - OIDC_USER_REFERENCE_CLAIM: 'sub', - OIDC_USER_ROLE_CLAIM: 'groups', - OIDC_ADMIN_ROLE_VALUE: 'admin', - OIDC_SCOPES: ['openid', 'profile', 'email', 'groups'], - OIDC_ALLOWED_EMAILS: [], -}; - -function user(role: UserContext['role']): UserContext { - return { userRef: 'alice', role }; -} - -describe('isAdmin', () => { - afterEach(() => { - disableOidcAuth(); +describe('STANDALONE_REQUEST_CONTEXT', () => { + it('has the fixed standalone identity shape', () => { + expect(STANDALONE_REQUEST_CONTEXT).toEqual({ + tenant_id: 'default', + subject: { + id: 'trueforge-default', + type: 'user', + display_name: 'Admin', + }, + is_admin: true, + user_credential: null, + }); }); +}); - it('is always true without OIDC (standalone)', () => { - disableOidcAuth(); - expect(isAdmin(user('user'))).toBe(true); - expect(isAdmin(LOCAL_USER_CONTEXT)).toBe(true); - }); +describe('resolveRequestContext', () => { + it('returns request_context when set by auth middleware', async () => { + const app = new Hono(); + app.get('/', c => { + c.set('request_context', STANDALONE_REQUEST_CONTEXT); + return c.json(resolveRequestContext(c)); + }); - it('checks role when auth is enabled', async () => { - globalThis.fetch = async () => - new Response( - JSON.stringify({ - issuer: 'https://issuer.example.com', - jwks_uri: 'https://issuer.example.com/jwks', - }), - { headers: { 'Content-Type': 'application/json' } }, - ); + const res = await app.request('/'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(STANDALONE_REQUEST_CONTEXT); + }); - const client = await initOidc(OIDC_CONFIG); - if (!client) { - throw new Error('OIDC client was not initialized'); - } - enableOidcAuth({ client, oidcConfig: OIDC_CONFIG }); + it('throws when request_context is missing', async () => { + const app = new Hono(); + app.get('/', c => { + try { + resolveRequestContext(c); + return c.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown'; + return c.json({ error: message }, 500); + } + }); - expect(isAdmin(user('admin'))).toBe(true); - expect(isAdmin(user('user'))).toBe(false); + const res = await app.request('/'); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: 'RequestContext missing; auth middleware did not run', + }); }); }); diff --git a/packages/trueforge/tests/unit/auth/middleware.test.ts b/packages/trueforge/tests/unit/auth/middleware.test.ts index 33211bba6..3a435c981 100644 --- a/packages/trueforge/tests/unit/auth/middleware.test.ts +++ b/packages/trueforge/tests/unit/auth/middleware.test.ts @@ -2,8 +2,12 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { HTTPException } from 'hono/http-exception'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import type { Configuration } from 'openid-client'; -import { adminAuthMiddleware, authMiddleware } from '../../../src/auth/middleware'; +import { createAdminAuthMiddleware, createAuthMiddleware } from '../../../src/auth/authenticator'; +import type { Authenticator } from '../../../src/auth/authenticator'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; +import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { disableOidcAuth, enableOidcAuth, initOidc } from '../../../src/auth/oidc'; +import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; import type { OIDCConfig } from '../../../src/config'; const ISSUER = 'https://issuer.example.com'; @@ -14,6 +18,7 @@ const OIDC_CONFIG: OIDCConfig = { OIDC_CLIENT_ID: AUDIENCE, OIDC_CLIENT_SECRET: 'harness-secret', OIDC_USER_REFERENCE_CLAIM: 'sub', + OIDC_USER_DISPLAY_NAME_CLAIM: 'name', OIDC_USER_ROLE_CLAIM: 'groups', OIDC_ADMIN_ROLE_VALUE: 'admin', OIDC_SCOPES: ['openid', 'profile', 'email', 'groups'], @@ -27,7 +32,7 @@ function json(body: unknown, status = 200): Response { }); } -function createApp() { +function createApp(authenticator: Authenticator) { const app = new OpenAPIHono(); app.onError((error, c) => { if (error instanceof HTTPException) { @@ -44,40 +49,38 @@ function createApp() { app.get('/api/v1/openapi', c => c.json({ public: true })); const models = new OpenAPIHono(); - models.use('*', authMiddleware); - models.get('/', c => c.json({ ok: true, user: c.get('user_context') })); + models.use('*', createAuthMiddleware(authenticator)); + models.get('/', c => c.json({ ok: true, user: c.get('request_context') })); app.route('/api/v1/models', models); const settings = new OpenAPIHono(); - settings.use('*', adminAuthMiddleware); - settings.get('/', c => c.json({ ok: true, user: c.get('user_context') })); + settings.use('*', createAdminAuthMiddleware(authenticator)); + settings.get('/', c => c.json({ ok: true, user: c.get('request_context') })); app.route('/api/v1/settings', settings); return app; } -describe('authMiddleware', () => { - it('allows settings without admin role when auth is disabled', async () => { - disableOidcAuth(); - const res = await createApp().request('/api/v1/settings'); +describe('createAuthMiddleware / createAdminAuthMiddleware', () => { + it('allows settings for standalone authenticator (always admin)', async () => { + const res = await createApp(new StandaloneAuthenticator()).request('/api/v1/settings'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'trueforge-default', role: 'admin' }, + user: STANDALONE_REQUEST_CONTEXT, }); }); - it('sets default user when auth is disabled', async () => { - disableOidcAuth(); - const res = await createApp().request('/api/v1/models'); + it('sets standalone request context when using StandaloneAuthenticator', async () => { + const res = await createApp(new StandaloneAuthenticator()).request('/api/v1/models'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'trueforge-default', role: 'admin' }, + user: STANDALONE_REQUEST_CONTEXT, }); }); - describe('when auth is enabled', () => { + describe('with OidcAuthenticator', () => { const realFetch = globalThis.fetch; let privateKey: Awaited>['privateKey']; let oidcClient: Configuration; @@ -131,12 +134,16 @@ describe('authMiddleware', () => { sub?: string; groups?: string[]; email?: string; + name?: string; exp?: string | number; }): Promise { const claims: Record = { groups: params?.groups ?? [] }; if (params?.email !== undefined) { claims['email'] = params.email; } + if (params?.name !== undefined) { + claims['name'] = params.name; + } return new SignJWT(claims) .setProtectedHeader({ alg: 'RS256', kid: 'test-kid' }) .setIssuer(params?.issuer ?? ISSUER) @@ -147,14 +154,32 @@ describe('authMiddleware', () => { .sign(privateKey); } + function oidcRequestContext(params: { + subjectId: string; + isAdmin: boolean; + authorization: string; + displayName?: string; + }) { + return { + tenant_id: 'default', + subject: { + id: params.subjectId, + type: 'user' as const, + display_name: params.displayName ?? params.subjectId, + }, + is_admin: params.isAdmin, + user_credential: { authorization: params.authorization }, + }; + } + it('returns 401 when the id_token cookie and Bearer token are missing', async () => { - const res = await createApp().request('/api/v1/models'); + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models'); expect(res.status).toBe(401); expect(await res.json()).toEqual({ error: { message: 'Authentication required' } }); }); it('returns 401 when the token is invalid', async () => { - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Cookie: 'id_token=not-a-jwt' }, }); expect(res.status).toBe(401); @@ -162,7 +187,7 @@ describe('authMiddleware', () => { }); it('returns 401 when the Bearer token is invalid', async () => { - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Authorization: 'Bearer not-a-jwt' }, }); expect(res.status).toBe(401); @@ -171,7 +196,7 @@ describe('authMiddleware', () => { it('returns 401 when the token has the wrong issuer', async () => { const token = await createIdToken({ issuer: 'https://other.example.com' }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Cookie: `id_token=${token}` }, }); expect(res.status).toBe(401); @@ -180,7 +205,7 @@ describe('authMiddleware', () => { it('returns 401 when the token has the wrong audience', async () => { const token = await createIdToken({ audience: 'other-client' }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Cookie: `id_token=${token}` }, }); expect(res.status).toBe(401); @@ -189,41 +214,49 @@ describe('authMiddleware', () => { it('returns 401 when the token is expired', async () => { const token = await createIdToken({ exp: Math.floor(Date.now() / 1000) - 60 }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Cookie: `id_token=${token}` }, }); expect(res.status).toBe(401); expect(await res.json()).toEqual({ error: { message: 'Authentication required' } }); }); - it('sets user context from claims (reference claim + role)', async () => { + it('sets request context from claims (reference claim + role)', async () => { const token = await createIdToken({ sub: 'alice', groups: ['admin'] }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Cookie: `id_token=${token}` }, }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'alice', role: 'admin' }, + user: oidcRequestContext({ + subjectId: 'alice', + isAdmin: true, + authorization: `Bearer ${token}`, + }), }); }); - it('sets user context from Authorization Bearer ID token', async () => { + it('sets request context from Authorization Bearer ID token', async () => { const token = await createIdToken({ sub: 'alice', groups: ['admin'] }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'alice', role: 'admin' }, + user: oidcRequestContext({ + subjectId: 'alice', + isAdmin: true, + authorization: `Bearer ${token}`, + }), }); }); it('prefers Bearer over cookie when both are present', async () => { const cookieToken = await createIdToken({ sub: 'cookie-user', groups: [] }); const bearerToken = await createIdToken({ sub: 'bearer-user', groups: ['admin'] }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Cookie: `id_token=${cookieToken}`, Authorization: `Bearer ${bearerToken}`, @@ -232,13 +265,17 @@ describe('authMiddleware', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'bearer-user', role: 'admin' }, + user: oidcRequestContext({ + subjectId: 'bearer-user', + isAdmin: true, + authorization: `Bearer ${bearerToken}`, + }), }); }); it('ignores non-Bearer Authorization and falls back to cookie', async () => { const token = await createIdToken({ sub: 'alice', groups: ['admin'] }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Authorization: `Basic ${token}`, Cookie: `id_token=${token}`, @@ -247,37 +284,49 @@ describe('authMiddleware', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'alice', role: 'admin' }, + user: oidcRequestContext({ + subjectId: 'alice', + isAdmin: true, + authorization: `Bearer ${token}`, + }), }); }); it('allows settings when the caller has admin role', async () => { const token = await createIdToken({ sub: 'alice', groups: ['admin'] }); - const res = await createApp().request('/api/v1/settings', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/settings', { headers: { Cookie: `id_token=${token}` }, }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'alice', role: 'admin' }, + user: oidcRequestContext({ + subjectId: 'alice', + isAdmin: true, + authorization: `Bearer ${token}`, + }), }); }); it('allows settings with Bearer when the caller has admin role', async () => { const token = await createIdToken({ sub: 'alice', groups: ['admin'] }); - const res = await createApp().request('/api/v1/settings', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/settings', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'alice', role: 'admin' }, + user: oidcRequestContext({ + subjectId: 'alice', + isAdmin: true, + authorization: `Bearer ${token}`, + }), }); }); it('returns 403 on settings when the caller is not admin', async () => { const token = await createIdToken({ sub: 'bob', groups: ['everyone'] }); - const res = await createApp().request('/api/v1/settings', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/settings', { headers: { Cookie: `id_token=${token}` }, }); expect(res.status).toBe(403); @@ -290,7 +339,7 @@ describe('authMiddleware', () => { oidcConfig: { ...OIDC_CONFIG, OIDC_ALLOWED_EMAILS: ['*@company.com'] }, }); const token = await createIdToken({ sub: 'alice', groups: ['admin'], email: 'alice@elsewhere.com' }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Cookie: `id_token=${token}` }, }); expect(res.status).toBe(401); @@ -303,13 +352,17 @@ describe('authMiddleware', () => { oidcConfig: { ...OIDC_CONFIG, OIDC_ALLOWED_EMAILS: ['*@company.com'] }, }); const token = await createIdToken({ sub: 'alice', groups: ['admin'], email: 'alice@company.com' }); - const res = await createApp().request('/api/v1/models', { + const res = await createApp(new OidcAuthenticator()).request('/api/v1/models', { headers: { Cookie: `id_token=${token}` }, }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, - user: { userRef: 'alice', role: 'admin' }, + user: oidcRequestContext({ + subjectId: 'alice', + isAdmin: true, + authorization: `Bearer ${token}`, + }), }); }); @@ -320,7 +373,7 @@ describe('authMiddleware', () => { '/api/v1/mcp-servers/oauth/callback', '/api/v1/openapi', ])('does not gate public mount %s', async path => { - const res = await createApp().request(path); + const res = await createApp(new OidcAuthenticator()).request(path); expect(res.status).toBe(200); expect(await res.json()).toEqual({ public: true }); }); diff --git a/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts b/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts index cf14c7205..f9065ad8f 100644 --- a/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts +++ b/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts @@ -1,5 +1,4 @@ -import { TENANT_ID } from '../../../src/apis/sessions'; -import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import type { IMcpServerWithAuthStore } from '../../../src/db/mcpServerStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; @@ -70,7 +69,7 @@ describe('getMcpConnection', () => { try { const record = await mcpServerStore.upsertServer({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'oauth-mcp', manifest: { type: 'remote', @@ -82,12 +81,12 @@ describe('getMcpConnection', () => { }); const connection = await getMcpConnection({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'oauth-mcp', store: mcpServerStore, tokenStore, clientName: 'test-client', - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toBeDefined(); if (connection === undefined) { @@ -120,7 +119,7 @@ describe('getMcpConnection', () => { it('returns Bearer headers when a usable token is already stored', async () => { const record = await mcpServerStore.upsertServer({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'tokened-mcp', manifest: { type: 'remote', @@ -132,7 +131,7 @@ describe('getMcpConnection', () => { }); await tokenStore.saveToken({ id: record.id, - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, token: { accessToken: 'live-access', refreshToken: null, @@ -142,12 +141,12 @@ describe('getMcpConnection', () => { }); const connection = await getMcpConnection({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'tokened-mcp', store: mcpServerStore, tokenStore, clientName: 'test-client', - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toBeDefined(); if (connection === undefined || typeof connection.headers !== 'function') { @@ -160,7 +159,7 @@ describe('getMcpConnection', () => { it('returns empty static headers when the server has no auth', async () => { await mcpServerStore.upsertServer({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'open-mcp', manifest: { type: 'remote', @@ -171,12 +170,12 @@ describe('getMcpConnection', () => { }); const connection = await getMcpConnection({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'open-mcp', store: mcpServerStore, tokenStore, clientName: 'test-client', - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toBeDefined(); if (connection === undefined) { @@ -189,7 +188,7 @@ describe('getMcpConnection', () => { it('returns configured static headers for header-auth servers', async () => { await mcpServerStore.upsertServer({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'header-mcp', manifest: { type: 'remote', @@ -201,12 +200,12 @@ describe('getMcpConnection', () => { }); const connection = await getMcpConnection({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'header-mcp', store: mcpServerStore, tokenStore, clientName: 'test-client', - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toBeDefined(); if (connection === undefined) { @@ -219,7 +218,7 @@ describe('getMcpConnection', () => { it('skips local DCR for truefoundry servers even when wire auth is dcr', async () => { await mcpServerStore.upsertServer({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'tfy-mcp', manifest: { type: 'truefoundry', @@ -231,12 +230,12 @@ describe('getMcpConnection', () => { }); const connection = await getMcpConnection({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'tfy-mcp', store: mcpServerStore, tokenStore, clientName: 'test-client', - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toEqual({ url: 'https://gateway.example/mcp-server/tfy-mcp', @@ -247,12 +246,12 @@ describe('getMcpConnection', () => { it('returns undefined when the server is not registered', async () => { await expect( getMcpConnection({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'missing-mcp', store: mcpServerStore, tokenStore, clientName: 'test-client', - userRef: LOCAL_USER_CONTEXT.userRef, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }), ).resolves.toBeUndefined(); }); diff --git a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts index b3d763f79..72391f11f 100644 --- a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts +++ b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts @@ -1,6 +1,5 @@ import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; import { HTTPException } from 'hono/http-exception'; -import { TENANT_ID } from '../../../src/apis/sessions'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; @@ -32,7 +31,7 @@ describe('validateAgentSpec', () => { await migrateSqliteToLatest(db); const modelProviderStore = new SqliteModelProviderStore(db); await modelProviderStore.upsertProvider({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'test-provider', manifest: { // Caller-named, so `custom` is the only type it can be. @@ -66,7 +65,7 @@ describe('validateAgentSpec', () => { await expect( getModelDetails({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'test-provider/test-model', store: stores.modelProviderStore, }), @@ -89,7 +88,7 @@ describe('validateAgentSpec', () => { model: { name: 'not-a-fqn' }, instructions: 'test', }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).rejects.toMatchObject({ @@ -106,7 +105,7 @@ describe('validateAgentSpec', () => { model: { name: 'missing-provider/test-model' }, instructions: 'test', }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).rejects.toMatchObject({ @@ -123,7 +122,7 @@ describe('validateAgentSpec', () => { model: { name: 'test-provider/missing-model' }, instructions: 'test', }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).rejects.toMatchObject({ @@ -140,7 +139,7 @@ describe('validateAgentSpec', () => { model: { name: 'test-provider/test-model', params: { reasoning_effort: 'medium' } }, instructions: 'test', }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).rejects.toMatchObject({ @@ -158,7 +157,7 @@ describe('validateAgentSpec', () => { instructions: 'test', mcp_servers: [{ name: 'missing-mcp' }], }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).rejects.toMatchObject({ @@ -176,7 +175,7 @@ describe('validateAgentSpec', () => { instructions: 'test', skills: [{ name: 'missing-skill' }], }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).rejects.toMatchObject({ @@ -194,7 +193,7 @@ describe('validateAgentSpec', () => { instructions: 'test', config: { sandbox: { enabled: true } }, }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).rejects.toMatchObject({ @@ -206,7 +205,7 @@ describe('validateAgentSpec', () => { it('rejects skills when no sandbox provider is configured', async () => { const stores = await setup(); await stores.skillStore.upsertSkill({ - tenant_id: TENANT_ID, + tenant_id: 'default', name: 'demo', manifest: { type: 'git', @@ -224,7 +223,7 @@ describe('validateAgentSpec', () => { instructions: 'test', skills: [{ name: 'demo' }], }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).rejects.toMatchObject({ @@ -236,7 +235,7 @@ describe('validateAgentSpec', () => { it('admits sandbox.enabled when a sandbox provider row exists', async () => { const stores = await setup(); await stores.sandboxProviderStore.upsertSandboxProvider({ - tenant_id: TENANT_ID, + tenant_id: 'default', manifest: { type: 'daytona', auth: { api_key: 'dtn-test' }, @@ -257,7 +256,7 @@ describe('validateAgentSpec', () => { instructions: 'test', config: { sandbox: { enabled: true } }, }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).resolves.toBeUndefined(); @@ -278,10 +277,10 @@ describe('validateAgentSpec', () => { instructions: 'test', config: { sandbox: { enabled: true } }, }), - tenant_id: TENANT_ID, + tenant_id: 'default', ...stores, }), ).resolves.toBeUndefined(); - expect(await stores.sandboxProviderStore.getSandboxProvider(TENANT_ID)).toBeUndefined(); + expect(await stores.sandboxProviderStore.getSandboxProvider('default')).toBeUndefined(); }); }); From f76ac0c704aae6c90604a9a7362a457b15e0fab9 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Thu, 3 Sep 2026 04:13:31 +0000 Subject: [PATCH 2/9] Regenerate OpenAPI document and TypeScript SDK --- .github/fern/openapi/openapi.json | 49 +++++++++++++------ docs/openapi.json | 49 +++++++++++++------ packages/trueforge-sdk/reference.md | 2 +- .../src/api/resources/auth/client/Client.ts | 2 +- .../src/api/types/GetMeResponse.ts | 11 ++--- .../src/api/types/GetMeResponseType.ts | 8 --- .../src/api/types/GetMeSubject.ts | 12 +++++ .../src/api/types/GetMeSubjectType.ts | 8 +++ packages/trueforge-sdk/src/api/types/index.ts | 3 +- .../src/serialization/types/GetMeResponse.ts | 14 +++--- .../serialization/types/GetMeResponseType.ts | 14 ------ .../src/serialization/types/GetMeSubject.ts | 21 ++++++++ .../serialization/types/GetMeSubjectType.ts | 12 +++++ .../src/serialization/types/index.ts | 3 +- .../trueforge-sdk/tests/wire/auth.test.ts | 16 ++++-- 15 files changed, 153 insertions(+), 71 deletions(-) delete mode 100644 packages/trueforge-sdk/src/api/types/GetMeResponseType.ts create mode 100644 packages/trueforge-sdk/src/api/types/GetMeSubject.ts create mode 100644 packages/trueforge-sdk/src/api/types/GetMeSubjectType.ts delete mode 100644 packages/trueforge-sdk/src/serialization/types/GetMeResponseType.ts create mode 100644 packages/trueforge-sdk/src/serialization/types/GetMeSubject.ts create mode 100644 packages/trueforge-sdk/src/serialization/types/GetMeSubjectType.ts diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 410fd969b..f36d2d106 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -1342,27 +1342,48 @@ }, "GetMeResponse": { "properties": { - "email": { - "description": "User email from the ID token when connected; `\"default\"` when anonymous.", + "is_admin": { + "description": "Whether the caller has admin privileges.", + "type": "boolean" + }, + "subject": { + "$ref": "#/components/schemas/GetMeSubject" + }, + "tenant_id": { + "description": "Tenant scope for the authenticated caller.", + "type": "string" + } + }, + "required": [ + "tenant_id", + "subject", + "is_admin" + ], + "type": "object" + }, + "GetMeSubject": { + "properties": { + "display_name": { + "description": "Human-readable name for the caller.", "type": "string" }, - "role": { - "description": "Caller role.", + "id": { + "description": "Stable subject identifier for the caller.", "type": "string" }, "type": { - "description": "Session kind: `default` when no valid OIDC session; `oidc-connected` after a successful browser login.", + "description": "Subject kind: interactive user or virtual account.", "enum": [ - "default", - "oidc-connected" + "user", + "virtualaccount" ], "type": "string" } }, "required": [ + "id", "type", - "email", - "role" + "display_name" ], "type": "object" }, @@ -4823,14 +4844,14 @@ "securitySchemes": { "BearerAuth": { "bearerFormat": "JWT", - "description": "ID token (`Authorization: Bearer `). Required on protected routes. Browser sessions may use the HttpOnly `id_token` cookie instead.", + "description": "Caller credential (`Authorization: Bearer `). Required on protected routes when auth is enabled. Browser sessions may use the HttpOnly `id_token` or `accessToken` cookie instead.", "scheme": "bearer", "type": "http" } } }, "info": { - "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone deployments (no OIDC) accept requests without credentials — middleware stamps a local default user. When OIDC is configured, protected routes require a valid `id_token` cookie or `Authorization: Bearer` ID token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", + "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", "version": "0.2.0-rc.0" }, @@ -5529,7 +5550,7 @@ }, "/api/v1/auth/me": { "get": { - "description": "Returns the authenticated caller identity. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` ID token (401 otherwise). When auth is disabled, returns the default identity.", + "description": "Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", "responses": { "200": { "content": { @@ -5539,7 +5560,7 @@ } } }, - "description": "Session type and identity for the current request." + "description": "Caller identity for the current request." }, "401": { "content": { @@ -5549,7 +5570,7 @@ } } }, - "description": "Auth is enabled and the request has no valid cookie or Bearer ID token." + "description": "Auth is enabled and the request has no valid cookie or Bearer token." } }, "summary": "Current session", diff --git a/docs/openapi.json b/docs/openapi.json index 410fd969b..f36d2d106 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1342,27 +1342,48 @@ }, "GetMeResponse": { "properties": { - "email": { - "description": "User email from the ID token when connected; `\"default\"` when anonymous.", + "is_admin": { + "description": "Whether the caller has admin privileges.", + "type": "boolean" + }, + "subject": { + "$ref": "#/components/schemas/GetMeSubject" + }, + "tenant_id": { + "description": "Tenant scope for the authenticated caller.", + "type": "string" + } + }, + "required": [ + "tenant_id", + "subject", + "is_admin" + ], + "type": "object" + }, + "GetMeSubject": { + "properties": { + "display_name": { + "description": "Human-readable name for the caller.", "type": "string" }, - "role": { - "description": "Caller role.", + "id": { + "description": "Stable subject identifier for the caller.", "type": "string" }, "type": { - "description": "Session kind: `default` when no valid OIDC session; `oidc-connected` after a successful browser login.", + "description": "Subject kind: interactive user or virtual account.", "enum": [ - "default", - "oidc-connected" + "user", + "virtualaccount" ], "type": "string" } }, "required": [ + "id", "type", - "email", - "role" + "display_name" ], "type": "object" }, @@ -4823,14 +4844,14 @@ "securitySchemes": { "BearerAuth": { "bearerFormat": "JWT", - "description": "ID token (`Authorization: Bearer `). Required on protected routes. Browser sessions may use the HttpOnly `id_token` cookie instead.", + "description": "Caller credential (`Authorization: Bearer `). Required on protected routes when auth is enabled. Browser sessions may use the HttpOnly `id_token` or `accessToken` cookie instead.", "scheme": "bearer", "type": "http" } } }, "info": { - "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone deployments (no OIDC) accept requests without credentials — middleware stamps a local default user. When OIDC is configured, protected routes require a valid `id_token` cookie or `Authorization: Bearer` ID token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", + "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", "version": "0.2.0-rc.0" }, @@ -5529,7 +5550,7 @@ }, "/api/v1/auth/me": { "get": { - "description": "Returns the authenticated caller identity. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` ID token (401 otherwise). When auth is disabled, returns the default identity.", + "description": "Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", "responses": { "200": { "content": { @@ -5539,7 +5560,7 @@ } } }, - "description": "Session type and identity for the current request." + "description": "Caller identity for the current request." }, "401": { "content": { @@ -5549,7 +5570,7 @@ } } }, - "description": "Auth is enabled and the request has no valid cookie or Bearer ID token." + "description": "Auth is enabled and the request has no valid cookie or Bearer token." } }, "summary": "Current session", diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 513a83723..d1640552e 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -341,7 +341,7 @@ await client.agents.delete("agent_id");
-Returns the authenticated caller identity. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` ID token (401 otherwise). When auth is disabled, returns the default identity. +Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.
diff --git a/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts b/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts index 6b9e64e21..211196a6d 100644 --- a/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts @@ -23,7 +23,7 @@ export class AuthClient { } /** - * Returns the authenticated caller identity. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` ID token (401 otherwise). When auth is disabled, returns the default identity. + * Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity. * * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration. * diff --git a/packages/trueforge-sdk/src/api/types/GetMeResponse.ts b/packages/trueforge-sdk/src/api/types/GetMeResponse.ts index 9eed69410..ba26c2ac1 100644 --- a/packages/trueforge-sdk/src/api/types/GetMeResponse.ts +++ b/packages/trueforge-sdk/src/api/types/GetMeResponse.ts @@ -3,10 +3,9 @@ import type * as TrueForge from "../index.js"; export interface GetMeResponse { - /** User email from the ID token when connected; `"default"` when anonymous. */ - email: string; - /** Caller role. */ - role: string; - /** Session kind: `default` when no valid OIDC session; `oidc-connected` after a successful browser login. */ - type: TrueForge.GetMeResponseType; + /** Whether the caller has admin privileges. */ + isAdmin: boolean; + subject: TrueForge.GetMeSubject; + /** Tenant scope for the authenticated caller. */ + tenantId: string; } diff --git a/packages/trueforge-sdk/src/api/types/GetMeResponseType.ts b/packages/trueforge-sdk/src/api/types/GetMeResponseType.ts deleted file mode 100644 index 1779fa3af..000000000 --- a/packages/trueforge-sdk/src/api/types/GetMeResponseType.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -/** Session kind: `default` when no valid OIDC session; `oidc-connected` after a successful browser login. */ -export const GetMeResponseType = { - Default: "default", - OidcConnected: "oidc-connected", -} as const; -export type GetMeResponseType = (typeof GetMeResponseType)[keyof typeof GetMeResponseType]; diff --git a/packages/trueforge-sdk/src/api/types/GetMeSubject.ts b/packages/trueforge-sdk/src/api/types/GetMeSubject.ts new file mode 100644 index 000000000..a333d1cac --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/GetMeSubject.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface GetMeSubject { + /** Human-readable name for the caller. */ + displayName: string; + /** Stable subject identifier for the caller. */ + id: string; + /** Subject kind: interactive user or virtual account. */ + type: TrueForge.GetMeSubjectType; +} diff --git a/packages/trueforge-sdk/src/api/types/GetMeSubjectType.ts b/packages/trueforge-sdk/src/api/types/GetMeSubjectType.ts new file mode 100644 index 000000000..c5d322f5e --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/GetMeSubjectType.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +/** Subject kind: interactive user or virtual account. */ +export const GetMeSubjectType = { + User: "user", + Virtualaccount: "virtualaccount", +} as const; +export type GetMeSubjectType = (typeof GetMeSubjectType)[keyof typeof GetMeSubjectType]; diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index 5449fdeb5..82d5c4dbb 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -61,7 +61,8 @@ export * from "./GetCapabilitiesResponse.js"; export * from "./GetMcpServerCatalogResponse.js"; export * from "./GetMcpServerResponse.js"; export * from "./GetMeResponse.js"; -export * from "./GetMeResponseType.js"; +export * from "./GetMeSubject.js"; +export * from "./GetMeSubjectType.js"; export * from "./GetModelProviderCatalogResponse.js"; export * from "./GetModelProviderResponse.js"; export * from "./GetSandboxProviderCatalogResponse.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/GetMeResponse.ts b/packages/trueforge-sdk/src/serialization/types/GetMeResponse.ts index b723498a8..630f0973b 100644 --- a/packages/trueforge-sdk/src/serialization/types/GetMeResponse.ts +++ b/packages/trueforge-sdk/src/serialization/types/GetMeResponse.ts @@ -3,19 +3,19 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; -import { GetMeResponseType } from "./GetMeResponseType.js"; +import { GetMeSubject } from "./GetMeSubject.js"; export const GetMeResponse: core.serialization.ObjectSchema = core.serialization.object({ - email: core.serialization.string(), - role: core.serialization.string(), - type: GetMeResponseType, + isAdmin: core.serialization.property("is_admin", core.serialization.boolean()), + subject: GetMeSubject, + tenantId: core.serialization.property("tenant_id", core.serialization.string()), }); export declare namespace GetMeResponse { export interface Raw { - email: string; - role: string; - type: GetMeResponseType.Raw; + is_admin: boolean; + subject: GetMeSubject.Raw; + tenant_id: string; } } diff --git a/packages/trueforge-sdk/src/serialization/types/GetMeResponseType.ts b/packages/trueforge-sdk/src/serialization/types/GetMeResponseType.ts deleted file mode 100644 index 9e9a17458..000000000 --- a/packages/trueforge-sdk/src/serialization/types/GetMeResponseType.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as TrueForge from "../../api/index.js"; -import * as core from "../../core/index.js"; -import type * as serializers from "../index.js"; - -export const GetMeResponseType: core.serialization.Schema< - serializers.GetMeResponseType.Raw, - TrueForge.GetMeResponseType -> = core.serialization.enum_(["default", "oidc-connected"]); - -export declare namespace GetMeResponseType { - export type Raw = "default" | "oidc-connected"; -} diff --git a/packages/trueforge-sdk/src/serialization/types/GetMeSubject.ts b/packages/trueforge-sdk/src/serialization/types/GetMeSubject.ts new file mode 100644 index 000000000..9be6e10f4 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/GetMeSubject.ts @@ -0,0 +1,21 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { GetMeSubjectType } from "./GetMeSubjectType.js"; + +export const GetMeSubject: core.serialization.ObjectSchema = + core.serialization.object({ + displayName: core.serialization.property("display_name", core.serialization.string()), + id: core.serialization.string(), + type: GetMeSubjectType, + }); + +export declare namespace GetMeSubject { + export interface Raw { + display_name: string; + id: string; + type: GetMeSubjectType.Raw; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/GetMeSubjectType.ts b/packages/trueforge-sdk/src/serialization/types/GetMeSubjectType.ts new file mode 100644 index 000000000..ab766415a --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/GetMeSubjectType.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const GetMeSubjectType: core.serialization.Schema = + core.serialization.enum_(["user", "virtualaccount"]); + +export declare namespace GetMeSubjectType { + export type Raw = "user" | "virtualaccount"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index 5449fdeb5..82d5c4dbb 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -61,7 +61,8 @@ export * from "./GetCapabilitiesResponse.js"; export * from "./GetMcpServerCatalogResponse.js"; export * from "./GetMcpServerResponse.js"; export * from "./GetMeResponse.js"; -export * from "./GetMeResponseType.js"; +export * from "./GetMeSubject.js"; +export * from "./GetMeSubjectType.js"; export * from "./GetModelProviderCatalogResponse.js"; export * from "./GetModelProviderResponse.js"; export * from "./GetSandboxProviderCatalogResponse.js"; diff --git a/packages/trueforge-sdk/tests/wire/auth.test.ts b/packages/trueforge-sdk/tests/wire/auth.test.ts index d204c308c..5d5032c83 100644 --- a/packages/trueforge-sdk/tests/wire/auth.test.ts +++ b/packages/trueforge-sdk/tests/wire/auth.test.ts @@ -9,15 +9,23 @@ describe("AuthClient", () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); - const rawResponseBody = { email: "email", role: "role", type: "default" }; + const rawResponseBody = { + is_admin: true, + subject: { display_name: "display_name", id: "id", type: "user" }, + tenant_id: "tenant_id", + }; server.mockEndpoint().get("/api/v1/auth/me").respondWith().statusCode(200).jsonBody(rawResponseBody).build(); const response = await client.auth.me(); expect(response).toEqual({ - email: "email", - role: "role", - type: "default", + isAdmin: true, + subject: { + displayName: "display_name", + id: "id", + type: "user", + }, + tenantId: "tenant_id", }); }); From 1fdd83d60ea91e1f9283acb81641f1d6233702a8 Mon Sep 17 00:00:00 2001 From: thesujai Date: Thu, 3 Sep 2026 09:57:51 +0530 Subject: [PATCH 3/9] refactor: Simplify import statements and streamline function calls across multiple files --- packages/trueforge/src/apis/modelProviders.ts | 14 +---- packages/trueforge/src/apis/schedules.ts | 7 +-- packages/trueforge/src/apis/sessions.ts | 5 +- packages/trueforge/src/apis/turns.ts | 4 +- packages/trueforge/src/app.ts | 6 +- packages/trueforge/src/auth/claims.ts | 6 +- .../trueforge/src/auth/oidcAuthenticator.ts | 2 +- .../TrueFoundryServiceFoundryServerClient.ts | 56 +------------------ .../trueforge/tests/unit/apis/agents.test.ts | 2 + .../trueforge/tests/unit/apis/auth.test.ts | 25 ++++----- .../tests/unit/apis/capabilities.test.ts | 4 +- .../tests/unit/apis/mcpServers.test.ts | 8 ++- .../trueforge/tests/unit/apis/skills.test.ts | 3 + .../trueforge/tests/unit/auth/claims.test.ts | 9 ++- .../tests/unit/auth/middleware.test.ts | 4 +- 15 files changed, 47 insertions(+), 108 deletions(-) diff --git a/packages/trueforge/src/apis/modelProviders.ts b/packages/trueforge/src/apis/modelProviders.ts index 6dcc0efa9..d73e6bb83 100644 --- a/packages/trueforge/src/apis/modelProviders.ts +++ b/packages/trueforge/src/apis/modelProviders.ts @@ -69,9 +69,7 @@ function toWireProvider(record: ModelProviderRecord): ConfiguredModelProvider { export function createModelProvidersRouter(deps: ModelProvidersRouterDeps) { const listHandler: RouteHandler = async c => { const requestContext = deps.resolveRequestContext(c); - const records = await deps - .resolveModelProviderStore(c) - .listProviders({ tenant_id: requestContext.tenant_id }); + const records = await deps.resolveModelProviderStore(c).listProviders({ tenant_id: requestContext.tenant_id }); return c.json({ data: records.map(toWireProvider) }, 200); }; @@ -110,18 +108,12 @@ export function createModelProvidersRouter(deps: ModelProvidersRou // Lock → resolve secret from that snapshot → upsert, all in one txn so concurrent keep // cannot re-write a secret over a rotate that committed in between. const record = await deps.withTransaction(async transaction => { - const existing = await store.getProviderForUpdate( - { tenant_id: requestContext.tenant_id, name }, - transaction, - ); + const existing = await store.getProviderForUpdate({ tenant_id: requestContext.tenant_id, name }, transaction); const manifest = resolveModelProviderManifestForWrite({ incoming: provider, existing: existing?.manifest, }); - return store.upsertProvider( - { tenant_id: requestContext.tenant_id, name, manifest }, - transaction, - ); + return store.upsertProvider({ tenant_id: requestContext.tenant_id, name, manifest }, transaction); }); return c.json({ data: toWireProvider(record) }, 200); } catch (error) { diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index 0a55cec42..242e365ac 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -3,7 +3,7 @@ */ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import { InvalidPageTokenError, type Sessions } from '@truefoundry/trueforge-core/agent-session'; -import type { ResolveRequestContext, RequestContext } from '../auth/identity'; +import type { RequestContext, ResolveRequestContext } from '../auth/identity'; import { ScheduleAgentNotFoundError, startScheduleRun } from '../controller/scheduleDispatch'; import type { IAgentStore } from '../db/agentStore'; import { @@ -107,10 +107,7 @@ const FORBIDDEN_SCHEDULE_ACCESS = 'Only the schedule creator can access this sch * Standalone auth stamps `is_admin: true` on the sole identity, which already * owns everything it created — so admin bypass is a no-op there. */ -function canAccessSchedule( - requestContext: Pick, - createdBy: string, -): boolean { +function canAccessSchedule(requestContext: Pick, createdBy: string): boolean { return requestContext.is_admin || requestContext.subject.id === createdBy; } diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index da459c83d..d38dd691e 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -275,7 +275,10 @@ function createGetOrCreateSessionByExternalIdHandler( created_by: requestContext.subject.id, agent, }); - if (!created && !checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: session.record.created_by })) { + if ( + !created && + !checkSessionAccess({ subject_id: requestContext.subject.id, createdBy: session.record.created_by }) + ) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); } return c.json({ data: toWireSession(session.record) }, created ? 201 : 200); diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index ca9fece13..f7f3afb9f 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -789,9 +789,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { return c.json({ error: { message: `Turn not found: ${turnId}` } }, 404); } - const turnEventStream = deps.eventSubscriptions.get( - turnStreamId(requestContext.tenant_id, sessionId, turnId), - ); + const turnEventStream = deps.eventSubscriptions.get(turnStreamId(requestContext.tenant_id, sessionId, turnId)); // Admission check before SSE headers are sent, so it can still map to HTTP 412. try { diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index a8af84661..3ae35e336 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -23,11 +23,7 @@ import { createInternalSessionsRouter, createSessionsRouter } from './apis/sessi import { createSettingsRouter } from './apis/settings'; import { createAvailableSkillsRouter } from './apis/skills'; import { createTurnsRouter } from './apis/turns'; -import { - createAdminAuthMiddleware, - createAuthMiddleware, - type Authenticator, -} from './auth/authenticator'; +import { createAdminAuthMiddleware, createAuthMiddleware, type Authenticator } from './auth/authenticator'; import { resolveRequestContext } from './auth/identity'; import { StandaloneAuthenticator } from './auth/standaloneAuthenticator'; import type { McpCatalog } from './catalog/McpCatalog'; diff --git a/packages/trueforge/src/auth/claims.ts b/packages/trueforge/src/auth/claims.ts index e700ee191..e5525479c 100644 --- a/packages/trueforge/src/auth/claims.ts +++ b/packages/trueforge/src/auth/claims.ts @@ -64,8 +64,7 @@ export function toRequestContext(params: { assertEmailAllowed(claims, config); const subjectId = resolveUserRef(claims, config); const role = resolveRole(claims, config); - const displayName = - resolveOptionalStringClaim(claims, config.OIDC_USER_DISPLAY_NAME_CLAIM) ?? subjectId; + const displayName = resolveOptionalStringClaim(claims, config.OIDC_USER_DISPLAY_NAME_CLAIM) ?? subjectId; return { tenant_id: 'default', subject: { @@ -89,6 +88,8 @@ export interface AuthorizationRequestParams { * from configured claim names and {@link OIDCConfig.OIDC_SCOPES}. * `essential: true` on identity claims makes the IdP reject the * login outright if it can't actually produce that claim. + * Display name is requested via scopes/claims mapping but is not essential — + * {@link toRequestContext} falls back to the subject id when it is absent. * When an email allowlist is configured, `email` is also marked essential so * the IdP cannot complete login without a usable address to check. */ @@ -96,7 +97,6 @@ export function buildAuthorizationRequestParams(config: OIDCConfig): Authorizati const idTokenClaims: Record = { [config.OIDC_USER_REFERENCE_CLAIM]: { essential: true }, [config.OIDC_USER_ROLE_CLAIM]: { essential: true }, - [config.OIDC_USER_DISPLAY_NAME_CLAIM]: { essential: true }, }; if (config.OIDC_ALLOWED_EMAILS.length > 0) { idTokenClaims['email'] = { essential: true }; diff --git a/packages/trueforge/src/auth/oidcAuthenticator.ts b/packages/trueforge/src/auth/oidcAuthenticator.ts index f8be6367b..68b5979eb 100644 --- a/packages/trueforge/src/auth/oidcAuthenticator.ts +++ b/packages/trueforge/src/auth/oidcAuthenticator.ts @@ -2,8 +2,8 @@ import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { jwtVerify } from 'jose'; -import { toRequestContext, type IdTokenClaims } from './claims'; import type { Authenticator } from './authenticator'; +import { toRequestContext, type IdTokenClaims } from './claims'; import type { RequestContext } from './identity'; import { getOidcVerify } from './oidc'; import { extractRequestToken, toBearerAuthorization } from './token'; diff --git a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts index bb299dab8..96f48562c 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts @@ -34,7 +34,6 @@ const GetSessionResponseSchema = z.object({ export type GetSessionResponse = z.infer; - const ListResponseSchema = z.union([ z.array(z.unknown()), z.object({ @@ -162,62 +161,13 @@ export class TrueFoundryServiceFoundryServerClient { /** * `GET v1/session` for RequestContext mapping. - * 401/403 propagate; transport / non-auth upstream / parse failures → 502 with `{ cause }`. + * Transport / auth failures follow {@link #getJson}; schema mismatch → 502 with `{ cause }`. */ async getSession(accessToken: string): Promise { - const url = this.#url(SESSION_PATH); - const startedAt = Date.now(); - let response: Awaited>; - try { - response = await undiciFetch(url, { - method: 'GET', - headers: { - accept: 'application/json', - authorization: `Bearer ${accessToken}`, - }, - ...(this.#dispatcher ? { dispatcher: this.#dispatcher } : {}), - }); - } catch (error) { - this.#logger?.warn('TrueFoundry ServiceFoundry session request failed', { - url: url.href, - durationMs: Date.now() - startedAt, - ...extractErrorLogFields(error), - }); - throw new HTTPException(502, { - message: 'TrueFoundry ServiceFoundry server request failed', - cause: error, - }); - } - this.#logger?.info('TrueFoundry ServiceFoundry session request completed', { - url: url.href, - status: response.status, - durationMs: Date.now() - startedAt, - }); - if (response.status === 401 || response.status === 403) { - throw new HTTPException(response.status, { - message: 'TrueFoundry ServiceFoundry server rejected the request', - }); - } - if (!response.ok) { - const detail = await readServiceFoundryErrorMessage(response); - throw new HTTPException(502, { - message: `TrueFoundry ServiceFoundry server request failed: ${detail ?? `HTTP ${String(response.status)}`}`, - }); - } - - let payload: unknown; - try { - payload = await response.json(); - } catch (error) { - throw new HTTPException(502, { - message: 'TrueFoundry ServiceFoundry session response was not valid JSON', - cause: error, - }); - } - + const payload = await this.#getJson(this.#url(SESSION_PATH), accessToken); const parsed = GetSessionResponseSchema.safeParse(payload); if (!parsed.success) { - throw new HTTPException(502, { + throw new HTTPException(500, { message: 'TrueFoundry ServiceFoundry session response was malformed', cause: parsed.error, }); diff --git a/packages/trueforge/tests/unit/apis/agents.test.ts b/packages/trueforge/tests/unit/apis/agents.test.ts index 14808cdd6..1e3a39463 100644 --- a/packages/trueforge/tests/unit/apis/agents.test.ts +++ b/packages/trueforge/tests/unit/apis/agents.test.ts @@ -1,4 +1,5 @@ import { createAgentsRouter } from '../../../src/apis/agents'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; @@ -84,6 +85,7 @@ describe('agents router', () => { skillStore: new SqliteSkillStore(db), sandboxProviderStore: new SqliteSandboxProviderStore(db), withTransaction: callback => db.transaction().execute(callback), + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }); }); diff --git a/packages/trueforge/tests/unit/apis/auth.test.ts b/packages/trueforge/tests/unit/apis/auth.test.ts index e733c4545..a84eb520e 100644 --- a/packages/trueforge/tests/unit/apis/auth.test.ts +++ b/packages/trueforge/tests/unit/apis/auth.test.ts @@ -5,8 +5,8 @@ import winston from 'winston'; import { createAuthRouter } from '../../../src/apis/auth'; import { createAuthMiddleware } from '../../../src/auth/authenticator'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { disableOidcAuth, initOidc } from '../../../src/auth/oidc'; +import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; import configuration from '../../../src/config'; @@ -230,7 +230,7 @@ describe('auth router (auth enabled)', () => { expect.arrayContaining(['openid', 'profile', 'email', 'groups']), ); expect(JSON.parse(authUrl.searchParams.get('claims') ?? '{}')).toEqual({ - id_token: { sub: { essential: true }, groups: { essential: true }, name: { essential: true } }, + id_token: { sub: { essential: true }, groups: { essential: true } }, }); expect(cookieValue(setCookies(res), STATE_COOKIE)).toBeTruthy(); }); @@ -256,7 +256,7 @@ describe('auth router (auth enabled)', () => { ); expect(authUrl.searchParams.get('scope')?.split(' ')).not.toContain('groups'); expect(JSON.parse(authUrl.searchParams.get('claims') ?? '{}')).toEqual({ - id_token: { sub: { essential: true }, roles: { essential: true }, name: { essential: true } }, + id_token: { sub: { essential: true }, roles: { essential: true } }, }); }); @@ -297,7 +297,6 @@ describe('auth router (auth enabled)', () => { id_token: { sub: { essential: true }, groups: { essential: true }, - name: { essential: true }, email: { essential: true }, }, }); @@ -342,10 +341,9 @@ describe('auth router (auth enabled)', () => { // No `error` code → this is our own validation failure, so the attacker-supplied // description must not be reflected; the reason stays generic. const crafted = 'Your%20account%20is%20compromised%2C%20call%201-800-EVIL'; - const res = await createTestAuthRouter({ oidcClient }).request( - `/callback?state=any&error_description=${crafted}`, - { redirect: 'manual' }, - ); + const res = await createTestAuthRouter({ oidcClient }).request(`/callback?state=any&error_description=${crafted}`, { + redirect: 'manual', + }); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe('/?error=login_failed'); }); @@ -380,13 +378,10 @@ describe('auth router (auth enabled)', () => { } const token = await createIdToken(); - const res = await createTestAuthRouter({ oidcClient: restrictedClient }).request( - '/callback?code=abc&state=spent', - { - redirect: 'manual', - headers: { Cookie: `${ID_TOKEN_COOKIE}=${token}` }, - }, - ); + const res = await createTestAuthRouter({ oidcClient: restrictedClient }).request('/callback?code=abc&state=spent', { + redirect: 'manual', + headers: { Cookie: `${ID_TOKEN_COOKIE}=${token}` }, + }); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe('/?error=login_failed'); expect( diff --git a/packages/trueforge/tests/unit/apis/capabilities.test.ts b/packages/trueforge/tests/unit/apis/capabilities.test.ts index a6e135f2b..68c312569 100644 --- a/packages/trueforge/tests/unit/apis/capabilities.test.ts +++ b/packages/trueforge/tests/unit/apis/capabilities.test.ts @@ -6,11 +6,11 @@ import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import type { Configuration } from 'openid-client'; import { createLogger } from 'winston'; import { createCapabilitiesRouter } from '../../../src/apis/capabilities'; -import { createAuthMiddleware } from '../../../src/auth/authenticator'; import type { Authenticator } from '../../../src/auth/authenticator'; +import { createAuthMiddleware } from '../../../src/auth/authenticator'; import { resolveRequestContext } from '../../../src/auth/identity'; -import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { disableOidcAuth, enableOidcAuth, initOidc } from '../../../src/auth/oidc'; +import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; import type { OIDCConfig } from '../../../src/config'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; diff --git a/packages/trueforge/tests/unit/apis/mcpServers.test.ts b/packages/trueforge/tests/unit/apis/mcpServers.test.ts index c6c1dcb4d..69b329f7f 100644 --- a/packages/trueforge/tests/unit/apis/mcpServers.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpServers.test.ts @@ -400,7 +400,9 @@ describe('mcp-servers routers', () => { expect(await response.json()).toEqual({ data: configured({ ...putBodyWithDcr, url: newUrl }, 'auth_required'), }); - expect(await tokenStore.getToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id })).toBeUndefined(); + expect( + await tokenStore.getToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id }), + ).toBeUndefined(); expect(await tokenStore.getToken({ id: record.id, userRef: 'other-user' })).toBeUndefined(); expect(await tokenStore.consumePendingAuthorization({ state: 'stale-pending-state' })).toBeUndefined(); expect(await mcpServerStore.getClient({ id: record.id })).toMatchObject({ @@ -849,7 +851,9 @@ describe('mcp-servers routers', () => { expect(await response.json()).toEqual({ data: configured(putBodyWithDcr, 'auth_required'), }); - expect(await tokenStore.getToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id })).toBeUndefined(); + expect( + await tokenStore.getToken({ id: record.id, userRef: STANDALONE_REQUEST_CONTEXT.subject.id }), + ).toBeUndefined(); expect(await mcpServerStore.getClient({ id: record.id })).toEqual({ server: { authorizationEndpoint: 'https://auth.example.com/authorize', diff --git a/packages/trueforge/tests/unit/apis/skills.test.ts b/packages/trueforge/tests/unit/apis/skills.test.ts index 2aaecdf41..829372d33 100644 --- a/packages/trueforge/tests/unit/apis/skills.test.ts +++ b/packages/trueforge/tests/unit/apis/skills.test.ts @@ -1,5 +1,6 @@ import { createCatalogRouter } from '../../../src/apis/catalog'; import { createAvailableSkillsRouter, createSkillsRouter } from '../../../src/apis/skills'; +import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpCatalog } from '../../../src/catalog/McpCatalog'; import { ModelCatalog } from '../../../src/catalog/ModelCatalog'; import { SandboxCatalog } from '../../../src/catalog/SandboxCatalog'; @@ -53,6 +54,7 @@ describe('skills routers', () => { settingsRouter = createSkillsRouter({ skillStore, withTransaction: callback => db.transaction().execute(callback), + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }); catalogRouter = createCatalogRouter({ modelCatalog: ModelCatalog.load(), @@ -63,6 +65,7 @@ describe('skills routers', () => { availableRouter = createAvailableSkillsRouter({ skillStore, withTransaction: callback => db.transaction().execute(callback), + resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, }); }); diff --git a/packages/trueforge/tests/unit/auth/claims.test.ts b/packages/trueforge/tests/unit/auth/claims.test.ts index 7d0ba838f..e453b7fad 100644 --- a/packages/trueforge/tests/unit/auth/claims.test.ts +++ b/packages/trueforge/tests/unit/auth/claims.test.ts @@ -190,10 +190,10 @@ describe('buildAuthorizationRequestParams', () => { expect(scopes).toEqual(['openid', 'profile', 'email', 'groups']); }); - it('requests reference, role, and display-name claims as essential in the id_token', () => { + it('requests reference and role claims as essential in the id_token', () => { const { claims } = buildAuthorizationRequestParams(config({ OIDC_USER_ROLE_CLAIM: 'roles' })); expect(claims).toEqual({ - id_token: { sub: { essential: true }, roles: { essential: true }, name: { essential: true } }, + id_token: { sub: { essential: true }, roles: { essential: true } }, }); }); @@ -202,7 +202,7 @@ describe('buildAuthorizationRequestParams', () => { config({ OIDC_USER_REFERENCE_CLAIM: 'email', OIDC_USER_ROLE_CLAIM: 'roles' }), ); expect(claims).toEqual({ - id_token: { email: { essential: true }, roles: { essential: true }, name: { essential: true } }, + id_token: { email: { essential: true }, roles: { essential: true } }, }); }); @@ -210,7 +210,7 @@ describe('buildAuthorizationRequestParams', () => { const { claims } = buildAuthorizationRequestParams( config({ OIDC_USER_REFERENCE_CLAIM: 'groups', OIDC_USER_ROLE_CLAIM: 'groups' }), ); - expect(claims).toEqual({ id_token: { groups: { essential: true }, name: { essential: true } } }); + expect(claims).toEqual({ id_token: { groups: { essential: true } } }); }); it('requests email as essential when an allowlist is configured', () => { @@ -219,7 +219,6 @@ describe('buildAuthorizationRequestParams', () => { id_token: { sub: { essential: true }, groups: { essential: true }, - name: { essential: true }, email: { essential: true }, }, }); diff --git a/packages/trueforge/tests/unit/auth/middleware.test.ts b/packages/trueforge/tests/unit/auth/middleware.test.ts index 3a435c981..bd517fc31 100644 --- a/packages/trueforge/tests/unit/auth/middleware.test.ts +++ b/packages/trueforge/tests/unit/auth/middleware.test.ts @@ -2,11 +2,11 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { HTTPException } from 'hono/http-exception'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import type { Configuration } from 'openid-client'; -import { createAdminAuthMiddleware, createAuthMiddleware } from '../../../src/auth/authenticator'; import type { Authenticator } from '../../../src/auth/authenticator'; +import { createAdminAuthMiddleware, createAuthMiddleware } from '../../../src/auth/authenticator'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { disableOidcAuth, enableOidcAuth, initOidc } from '../../../src/auth/oidc'; +import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; import type { OIDCConfig } from '../../../src/config'; From f7e3581133be96a55c89208cbe6c9943df3959d9 Mon Sep 17 00:00:00 2001 From: thesujai Date: Thu, 3 Sep 2026 14:52:58 +0530 Subject: [PATCH 4/9] feat: Update RequestContext to include roles and unify authentication response structure across all modes --- .changeset/unified-request-context-auth.md | 2 +- packages/trueforge/src/apis/auth.ts | 8 ++-- packages/trueforge/src/apis/capabilities.ts | 4 +- packages/trueforge/src/apis/schedules.ts | 10 ++-- packages/trueforge/src/app.ts | 48 ++++++++++++------- packages/trueforge/src/auth/authenticator.ts | 28 +---------- packages/trueforge/src/auth/claims.ts | 9 ++-- .../trueforge/src/auth/createAuthenticator.ts | 27 +++++------ packages/trueforge/src/auth/identity.ts | 34 +++++++------ packages/trueforge/src/auth/middleware.ts | 45 +++++++++++++---- .../trueforge/src/auth/oidcAuthenticator.ts | 4 +- packages/trueforge/src/auth/token.ts | 10 ---- .../src/auth/trueFoundryAuthenticator.ts | 30 ++---------- packages/trueforge/src/config.ts | 21 ++++++++ packages/trueforge/src/main.ts | 29 +++++++---- packages/trueforge/src/routes/authRoutes.ts | 2 +- packages/trueforge/src/schemas/auth.ts | 16 +++---- .../TrueFoundryServiceFoundryServerClient.ts | 42 +++++++++++----- .../trueforge/tests/unit/apis/auth.test.ts | 18 ++++--- .../tests/unit/apis/capabilities.test.ts | 2 +- .../tests/unit/apis/mcpOAuth.test.ts | 2 +- .../tests/unit/apis/mcpServers.test.ts | 2 +- .../tests/unit/apis/schedules.test.ts | 6 +-- .../trueforge/tests/unit/auth/claims.test.ts | 32 ++++++------- .../tests/unit/auth/identity.test.ts | 4 +- .../tests/unit/auth/middleware.test.ts | 38 +++++++-------- 26 files changed, 256 insertions(+), 217 deletions(-) diff --git a/.changeset/unified-request-context-auth.md b/.changeset/unified-request-context-auth.md index 5a6c63e1d..990bc4327 100644 --- a/.changeset/unified-request-context-auth.md +++ b/.changeset/unified-request-context-auth.md @@ -2,4 +2,4 @@ '@truefoundry/trueforge': minor --- -Unify request-scoped RequestContext across standalone, OIDC, and TrueFoundry auth. `/auth/me` now returns `{ tenant_id, subject, is_admin }` (OpenAPI/SDK regen deferred to CI). +Unify request-scoped RequestContext across standalone, OIDC, and TrueFoundry auth. `/auth/me` returns `{ data: { tenant_id, subject, roles } }` (OpenAPI/SDK regen deferred to CI). diff --git a/packages/trueforge/src/apis/auth.ts b/packages/trueforge/src/apis/auth.ts index 4ebf1eb55..1b6bcc93a 100644 --- a/packages/trueforge/src/apis/auth.ts +++ b/packages/trueforge/src/apis/auth.ts @@ -123,9 +123,11 @@ export function createAuthRouter(params: { gated.openapi(meRoute, c => { const requestContext = resolveRequestContext(c); const body: GetMeResponse = { - tenant_id: requestContext.tenant_id, - subject: requestContext.subject, - is_admin: requestContext.is_admin, + data: { + tenant_id: requestContext.tenant_id, + subject: requestContext.subject, + roles: requestContext.roles, + }, }; return c.json(body, 200); }); diff --git a/packages/trueforge/src/apis/capabilities.ts b/packages/trueforge/src/apis/capabilities.ts index cf018aba1..f3cecde2f 100644 --- a/packages/trueforge/src/apis/capabilities.ts +++ b/packages/trueforge/src/apis/capabilities.ts @@ -1,7 +1,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; import type { Logger } from 'winston'; -import type { ResolveRequestContext } from '../auth/identity'; +import { hasAdminRole, type ResolveRequestContext } from '../auth/identity'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; import type { WithTransaction } from '../db/transaction'; import { getCapabilitiesRoute } from '../routes/capabilityRoutes'; @@ -43,7 +43,7 @@ export function createCapabilitiesRouter(deps: { deps.logger.warn('Sandbox image status check failed; reporting sandbox disabled', extractErrorLogFields(error)); } const sandboxEnabled = status === 'ready' || (status === undefined && isLocalSandboxFallbackEnabled()); - const settingsEnabled = requestContext.is_admin; + const settingsEnabled = hasAdminRole(requestContext); return c.json( { data: { diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index 242e365ac..f0ea585bd 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -3,7 +3,7 @@ */ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import { InvalidPageTokenError, type Sessions } from '@truefoundry/trueforge-core/agent-session'; -import type { RequestContext, ResolveRequestContext } from '../auth/identity'; +import { hasAdminRole, type RequestContext, type ResolveRequestContext } from '../auth/identity'; import { ScheduleAgentNotFoundError, startScheduleRun } from '../controller/scheduleDispatch'; import type { IAgentStore } from '../db/agentStore'; import { @@ -104,11 +104,11 @@ const FORBIDDEN_SCHEDULE_ACCESS = 'Only the schedule creator can access this sch /** * A schedule is visible to its creator, and to any admin. * - * Standalone auth stamps `is_admin: true` on the sole identity, which already + * Standalone auth stamps `roles: ['admin']` on the sole identity, which already * owns everything it created — so admin bypass is a no-op there. */ -function canAccessSchedule(requestContext: Pick, createdBy: string): boolean { - return requestContext.is_admin || requestContext.subject.id === createdBy; +function canAccessSchedule(requestContext: Pick, createdBy: string): boolean { + return hasAdminRole(requestContext) || requestContext.subject.id === createdBy; } export function createSchedulesRouter(deps: SchedulesRouterDeps) { @@ -123,7 +123,7 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps { @@ -187,21 +201,7 @@ export function createServerApp(deps: ServerDeps) { const app = new OpenAPIHono({ defaultHook: zodValidationHook }); const authMiddleware = createAuthMiddleware(deps.authenticator); const adminAuthMiddleware = createAdminAuthMiddleware(deps.authenticator); - const authEnabled = !(deps.authenticator instanceof StandaloneAuthenticator); - - function withAuth(router: OpenAPIHono): OpenAPIHono { - const shell = new OpenAPIHono(); - shell.use('*', authMiddleware); - shell.route('/', router); - return shell; - } - - function withAdminAuth(router: OpenAPIHono): OpenAPIHono { - const shell = new OpenAPIHono(); - shell.use('*', adminAuthMiddleware); - shell.route('/', router); - return shell; - } + const authEnabled = getTrueForgeMode() !== TrueForgeMode.Standalone; if (configuration.ACCESS_LOGS) { app.use('*', createAccessLogMiddleware(deps.logger)); @@ -227,6 +227,7 @@ export function createServerApp(deps: ServerDeps) { logger: deps.logger, resolveRequestContext, }), + authMiddleware, ), ); app.route( @@ -237,6 +238,7 @@ export function createServerApp(deps: ServerDeps) { withTransaction: deps.withTransaction, resolveRequestContext, }), + authMiddleware, ), ); app.route( @@ -248,6 +250,7 @@ export function createServerApp(deps: ServerDeps) { skillCatalog: deps.skillCatalog, sandboxCatalog: deps.sandboxCatalog, }), + authMiddleware, ), ); // Public MCP OAuth callback must be registered before the gated `/mcp-servers` mount so @@ -270,6 +273,7 @@ export function createServerApp(deps: ServerDeps) { logger: deps.logger, resolveRequestContext, }), + authMiddleware, ), ); app.route( @@ -280,6 +284,7 @@ export function createServerApp(deps: ServerDeps) { withTransaction: deps.withTransaction, resolveRequestContext, }), + authMiddleware, ), ); app.route( @@ -294,6 +299,7 @@ export function createServerApp(deps: ServerDeps) { withTransaction: deps.withTransaction, resolveRequestContext, }), + authMiddleware, ), ); app.route( @@ -317,6 +323,7 @@ export function createServerApp(deps: ServerDeps) { withTransaction: deps.withTransaction, resolveRequestContext, }), + authMiddleware, ), ); app.route( @@ -332,6 +339,7 @@ export function createServerApp(deps: ServerDeps) { logger: deps.logger, resolveRequestContext, }), + adminAuthMiddleware, ), ); app.route( @@ -346,6 +354,7 @@ export function createServerApp(deps: ServerDeps) { sandboxProviderStore: deps.sandboxProviderStore, resolveRequestContext, }), + authMiddleware, ), ); app.route( @@ -355,6 +364,7 @@ export function createServerApp(deps: ServerDeps) { sessionMetricsStore: deps.sessionMetricsStore, resolveRequestContext, }), + authMiddleware, ), ); app.route( @@ -374,6 +384,7 @@ export function createServerApp(deps: ServerDeps) { resolveRequestContext, logger: deps.logger, }), + authMiddleware, ), ); app.route( @@ -393,6 +404,7 @@ export function createServerApp(deps: ServerDeps) { logger: deps.logger, resolveRequestContext, }), + authMiddleware, ), ); diff --git a/packages/trueforge/src/auth/authenticator.ts b/packages/trueforge/src/auth/authenticator.ts index 04f112e7a..e7b06b862 100644 --- a/packages/trueforge/src/auth/authenticator.ts +++ b/packages/trueforge/src/auth/authenticator.ts @@ -1,33 +1,7 @@ -import type { Context, MiddlewareHandler } from 'hono'; -import { HTTPException } from 'hono/http-exception'; +import type { Context } from 'hono'; import type { RequestContext } from './identity'; -// tells TypeScript that c.set('request_context', …) / c.get('request_context') are valid and return RequestContextt -declare module 'hono' { - interface ContextVariableMap { - request_context?: RequestContext; - } -} - export interface Authenticator { authenticate(c: Context): Promise; } - -export function createAuthMiddleware(authenticator: Authenticator): MiddlewareHandler { - return async (c, next) => { - c.set('request_context', await authenticator.authenticate(c)); - return next(); - }; -} - -export function createAdminAuthMiddleware(authenticator: Authenticator): MiddlewareHandler { - return async (c, next) => { - const requestContext = await authenticator.authenticate(c); - if (!requestContext.is_admin) { - throw new HTTPException(403, { message: 'Admin access required' }); - } - c.set('request_context', requestContext); - return next(); - }; -} diff --git a/packages/trueforge/src/auth/claims.ts b/packages/trueforge/src/auth/claims.ts index e5525479c..91ecbc6f5 100644 --- a/packages/trueforge/src/auth/claims.ts +++ b/packages/trueforge/src/auth/claims.ts @@ -58,12 +58,11 @@ export function resolveRole(claims: IdTokenClaims, config: OIDCConfig): 'admin' export function toRequestContext(params: { claims: IdTokenClaims; config: OIDCConfig; - authorization: string; + user_credential: string; }): RequestContext { - const { claims, config, authorization } = params; + const { claims, config, user_credential } = params; assertEmailAllowed(claims, config); const subjectId = resolveUserRef(claims, config); - const role = resolveRole(claims, config); const displayName = resolveOptionalStringClaim(claims, config.OIDC_USER_DISPLAY_NAME_CLAIM) ?? subjectId; return { tenant_id: 'default', @@ -72,8 +71,8 @@ export function toRequestContext(params: { type: 'user', display_name: displayName, }, - is_admin: role === 'admin', - user_credential: { authorization }, + roles: claimValues(claims[config.OIDC_USER_ROLE_CLAIM]), + user_credential, }; } diff --git a/packages/trueforge/src/auth/createAuthenticator.ts b/packages/trueforge/src/auth/createAuthenticator.ts index 99b99e4d1..f6c4156ed 100644 --- a/packages/trueforge/src/auth/createAuthenticator.ts +++ b/packages/trueforge/src/auth/createAuthenticator.ts @@ -1,30 +1,25 @@ +import { TrueForgeMode } from '../config'; import type { TrueFoundryServiceFoundryServerClient } from '../truefoundry/TrueFoundryServiceFoundryServerClient'; import type { Authenticator } from './authenticator'; import { OidcAuthenticator } from './oidcAuthenticator'; import { StandaloneAuthenticator } from './standaloneAuthenticator'; import { TrueFoundryAuthenticator } from './trueFoundryAuthenticator'; -export enum TrueforgeMode { - Standalone = 'standalone', - Oidc = 'oidc', - TrueFoundry = 'truefoundry', -} +export { TrueForgeMode }; + +export type CreateAuthenticatorParams = + | { mode: TrueForgeMode.Standalone } + | { mode: TrueForgeMode.Oidc } + | { mode: TrueForgeMode.TrueFoundry; trueFoundryClient: TrueFoundryServiceFoundryServerClient }; /** Build the single process authenticator from resolved auth mode. */ -export function createAuthenticator(params: { - mode: TrueforgeMode; - trueFoundryClient?: TrueFoundryServiceFoundryServerClient; -}): Authenticator { +export function createAuthenticator(params: CreateAuthenticatorParams): Authenticator { switch (params.mode) { - case TrueforgeMode.TrueFoundry: { - if (!params.trueFoundryClient) { - throw new Error('TrueFoundryAuthenticator requires a ServiceFoundry client'); - } + case TrueForgeMode.TrueFoundry: return new TrueFoundryAuthenticator(params.trueFoundryClient); - } - case TrueforgeMode.Oidc: + case TrueForgeMode.Oidc: return new OidcAuthenticator(); - case TrueforgeMode.Standalone: + case TrueForgeMode.Standalone: return new StandaloneAuthenticator(); } } diff --git a/packages/trueforge/src/auth/identity.ts b/packages/trueforge/src/auth/identity.ts index 484c95d2b..b13ad5f6e 100644 --- a/packages/trueforge/src/auth/identity.ts +++ b/packages/trueforge/src/auth/identity.ts @@ -1,24 +1,17 @@ import type { Context } from 'hono'; -import { z } from 'zod'; -export const SubjectTypeSchema = z.enum(['user', 'virtualaccount']); -export type SubjectType = z.infer; - -export interface RequestSubject { +export type RequestSubject = { id: string; - type: SubjectType; + type: string; display_name: string; -} - -export interface UserCredential { - authorization: string; -} +}; export interface RequestContext { tenant_id: string; subject: RequestSubject; - is_admin: boolean; - user_credential: UserCredential | null; + roles: string[]; + /** Raw bearer token for the caller, or `null` when standalone has no credential. */ + user_credential: string | null; } export const STANDALONE_REQUEST_CONTEXT: RequestContext = { @@ -26,14 +19,20 @@ export const STANDALONE_REQUEST_CONTEXT: RequestContext = { subject: { id: 'trueforge-default', type: 'user', - display_name: 'Admin', + display_name: 'trueforge-default', }, - is_admin: true, + roles: ['admin'], user_credential: null, }; export type ResolveRequestContext = (c: Context) => RequestContext; +declare module 'hono' { + interface ContextVariableMap { + request_context?: RequestContext; + } +} + export function resolveRequestContext(c: Context): RequestContext { const requestContext = c.get('request_context'); if (requestContext === undefined) { @@ -41,3 +40,8 @@ export function resolveRequestContext(c: Context): RequestContext { } return requestContext; } + +/** Whether the caller holds the TrueForge admin role (`admin`). */ +export function hasAdminRole(requestContext: Pick): boolean { + return requestContext.roles.includes('admin'); +} diff --git a/packages/trueforge/src/auth/middleware.ts b/packages/trueforge/src/auth/middleware.ts index 49c742f29..16f25bf4b 100644 --- a/packages/trueforge/src/auth/middleware.ts +++ b/packages/trueforge/src/auth/middleware.ts @@ -1,17 +1,46 @@ -import type { Context } from 'hono'; +import type { Context, MiddlewareHandler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; import { jwtVerify } from 'jose'; +import { getTrueForgeMode, TrueForgeMode } from '../config'; +import type { Authenticator } from './authenticator'; import { toRequestContext, type IdTokenClaims } from './claims'; -import type { RequestContext } from './identity'; +import { hasAdminRole, type RequestContext } from './identity'; import { getOidcVerify } from './oidc'; -import { extractRequestToken, toBearerAuthorization } from './token'; +import { extractRequestToken } from './token'; -export { extractRequestToken, readBearerToken, toBearerAuthorization } from './token'; +export { extractRequestToken, readBearerToken } from './token'; + +export function createAuthMiddleware(authenticator: Authenticator): MiddlewareHandler { + return async (c, next) => { + c.set('request_context', await authenticator.authenticate(c)); + return next(); + }; +} + +export function createAdminAuthMiddleware(authenticator: Authenticator): MiddlewareHandler { + return async (c, next) => { + const requestContext = await authenticator.authenticate(c); + if (getTrueForgeMode() === TrueForgeMode.TrueFoundry) { + // TODO: stop treating `tenant-admin` as TrueForge admin once TFY role mapping is settled. + if (!requestContext.roles.includes('tenant-admin')) { + throw new HTTPException(403, { message: 'Admin access required' }); + } + } else if (!hasAdminRole(requestContext)) { + throw new HTTPException(403, { message: 'Admin access required' }); + } + c.set('request_context', requestContext); + return next(); + }; +} /** - * Bearer or cookie token → {@link RequestContext} when OIDC is enabled and the JWT is valid. - * Missing/invalid JWT → `undefined`. Claim mapping failures after a successful verify rethrow. - * Used by OIDC login soft-probes (callback already-authenticated redirect). + * Soft OIDC probe for login/callback — not request-gate middleware. + * + * Same JWT → {@link RequestContext} path as {@link OidcAuthenticator}, but missing/invalid + * tokens return `undefined` instead of throwing 401 (claim mapping failures after verify still + * rethrow so callers can clear a stale cookie). Needed where "no session yet" must not fail + * the request, e.g. redirect-if-already-authenticated on `/auth/callback`. */ export async function resolveOidcRequestContext(c: Context): Promise { const oidcVerify = getOidcVerify(); @@ -38,6 +67,6 @@ export async function resolveOidcRequestContext(c: Context): Promise { @@ -35,7 +35,7 @@ export class OidcAuthenticator implements Authenticator { return toRequestContext({ claims, config: oidcVerify.oidcConfig, - authorization: toBearerAuthorization(token), + user_credential: token, }); } catch (error) { throw new HTTPException(401, { message: 'Authentication required', cause: error }); diff --git a/packages/trueforge/src/auth/token.ts b/packages/trueforge/src/auth/token.ts index 04b151c30..632c241f3 100644 --- a/packages/trueforge/src/auth/token.ts +++ b/packages/trueforge/src/auth/token.ts @@ -38,13 +38,3 @@ export function readBearerToken(c: Context): string | undefined { export function extractRequestToken(c: Context): string | undefined { return readBearerToken(c) ?? readAccessTokenCookie({ context: c }) ?? readIdTokenCookie({ context: c }); } - -/** Format a raw token as the `UserCredential.authorization` value. */ -export function toBearerAuthorization(token: string): string { - return `${BEARER_PREFIX}${token}`; -} - -/** Strip the `Bearer ` prefix from a stored credential authorization value. */ -export function rawTokenFromCredential(authorization: string): string { - return parseBearerAuthorization(authorization) ?? authorization; -} diff --git a/packages/trueforge/src/auth/trueFoundryAuthenticator.ts b/packages/trueforge/src/auth/trueFoundryAuthenticator.ts index e9d03d177..5eb674bd1 100644 --- a/packages/trueforge/src/auth/trueFoundryAuthenticator.ts +++ b/packages/trueforge/src/auth/trueFoundryAuthenticator.ts @@ -3,27 +3,14 @@ import { HTTPException } from 'hono/http-exception'; import type { GetSessionResponse } from '../truefoundry/TrueFoundryServiceFoundryServerClient'; import type { Authenticator } from './authenticator'; -import { SubjectTypeSchema, type RequestContext, type SubjectType } from './identity'; -import { extractRequestToken, toBearerAuthorization } from './token'; - -const TENANT_ADMIN_ROLE = 'tenant-admin'; +import type { RequestContext } from './identity'; +import { extractRequestToken } from './token'; /** Narrow port used by the authenticator (avoids depending on the full SFY client). */ export interface TrueFoundrySessionClient { getSession(accessToken: string): Promise; } -function mapSubjectType(raw: string): SubjectType | undefined { - if (raw === 'user' || raw === 'virtualaccount') { - return SubjectTypeSchema.parse(raw); - } - // sfy server uses 'serviceaccount' for virtual accounts - if (raw === 'serviceaccount') { - return 'virtualaccount'; - } - return undefined; -} - export class TrueFoundryAuthenticator implements Authenticator { readonly #client: TrueFoundrySessionClient; @@ -38,23 +25,16 @@ export class TrueFoundryAuthenticator implements Authenticator { } const session = await this.#client.getSession(token); - const subjectType = mapSubjectType(session.user.subject.subjectType); - if (subjectType === undefined) { - throw new HTTPException(502, { - message: 'TrueFoundry session returned an unsupported subject type', - }); - } - const { subject } = session.user; return { tenant_id: session.user.tenantName, subject: { id: subject.subjectId, - type: subjectType, + type: subject.subjectType, display_name: subject.subjectDisplayName ?? subject.subjectSlug ?? subject.subjectId, }, - is_admin: session.user.roles.includes(TENANT_ADMIN_ROLE), - user_credential: { authorization: toBearerAuthorization(token) }, + roles: session.user.roles, + user_credential: token, }; } } diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index de6a5881f..5d7a8f1a7 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -685,6 +685,27 @@ export function isTrueFoundryModeEnabled( return config.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL !== undefined; } +/** Runtime auth/integration mode for this process. */ +export enum TrueForgeMode { + Standalone = 'standalone', + Oidc = 'oidc', + TrueFoundry = 'truefoundry', +} + +/** + * Resolve the active {@link TrueForgeMode} from configuration. + * TrueFoundry wins over OIDC when both would otherwise be set (startup already rejects that combo). + */ +export function getTrueForgeMode(config: ServerConfiguration = configuration): TrueForgeMode { + if (isTrueFoundryModeEnabled(config)) { + return TrueForgeMode.TrueFoundry; + } + if (isOidcConfigured(config)) { + return TrueForgeMode.Oidc; + } + return TrueForgeMode.Standalone; +} + // TrueFoundry mode authenticates each caller with their own gateway token, so browser SSO must be // off — the two auth models are mutually exclusive. if (isTrueFoundryModeEnabled(configuration) && isOidcConfigured(configuration)) { diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index b9fd8aaf8..2c0ae33fb 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -24,9 +24,17 @@ import { setCachedLocalSandboxSupport } from './sandbox/localRuntime'; let configuration: typeof import('./config').default; let isOidcConfigured: typeof import('./config').isOidcConfigured; let isTrueFoundryModeEnabled: typeof import('./config').isTrueFoundryModeEnabled; +let getTrueForgeMode: typeof import('./config').getTrueForgeMode; +let TrueForgeMode: typeof import('./config').TrueForgeMode; try { - ({ default: configuration, isOidcConfigured, isTrueFoundryModeEnabled } = await import('./config')); + ({ + default: configuration, + isOidcConfigured, + isTrueFoundryModeEnabled, + getTrueForgeMode, + TrueForgeMode, + } = await import('./config')); } catch (error) { console.error( 'Failed to start server: Failed to load configuration:', @@ -48,10 +56,9 @@ import type { RedisClientType } from 'redis'; import type { Logger } from 'winston'; import { createServerApp } from './app'; -import { createAuthenticator, TrueforgeMode } from './auth/createAuthenticator'; +import { createAuthenticator } from './auth/createAuthenticator'; import { resolveRequestContext } from './auth/identity'; import { initOidc } from './auth/oidc'; -import { rawTokenFromCredential } from './auth/token'; import { McpCatalog } from './catalog/McpCatalog'; import { ModelCatalog } from './catalog/ModelCatalog'; import { SandboxCatalog } from './catalog/SandboxCatalog'; @@ -103,7 +110,7 @@ function requireRequestCredentialToken(c: Context): string { message: 'Authentication token required to list or call TrueFoundry models and MCP servers', }); } - return rawTokenFromCredential(credential.authorization); + return credential; } /** @@ -330,19 +337,23 @@ async function createServerRuntime(persistence: ServerPersistence< const oidcClient = await initOidc(oidc); let authenticator; - if (isTrueFoundryModeEnabled(configuration)) { + const mode = getTrueForgeMode(configuration); + if (mode === TrueForgeMode.TrueFoundry) { + if (!isTrueFoundryModeEnabled(configuration)) { + throw new Error('TrueFoundry mode requires TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL'); + } authenticator = createAuthenticator({ - mode: TrueforgeMode.TrueFoundry, + mode: TrueForgeMode.TrueFoundry, trueFoundryClient: new TrueFoundryServiceFoundryServerClient({ serviceFoundryServerUrl: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL, logger, tls: { enabled: configuration.TRUEFOUNDRY_MTLS_ENABLED, dir: configuration.TRUEFOUNDRY_MTLS_CERTS_DIR }, }), }); - } else if (isOidcConfigured(configuration)) { - authenticator = createAuthenticator({ mode: TrueforgeMode.Oidc }); + } else if (mode === TrueForgeMode.Oidc) { + authenticator = createAuthenticator({ mode: TrueForgeMode.Oidc }); } else { - authenticator = createAuthenticator({ mode: TrueforgeMode.Standalone }); + authenticator = createAuthenticator({ mode: TrueForgeMode.Standalone }); } // Standalone is one process, so it owns the control loops too. diff --git a/packages/trueforge/src/routes/authRoutes.ts b/packages/trueforge/src/routes/authRoutes.ts index 5bb2585cb..837a2f1c8 100644 --- a/packages/trueforge/src/routes/authRoutes.ts +++ b/packages/trueforge/src/routes/authRoutes.ts @@ -63,7 +63,7 @@ export const meRoute = createRoute({ tags: [OpenApiTag.AUTH], summary: 'Current session', description: - 'Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled ' + + 'Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled ' + 'this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is ' + 'disabled, returns the standalone default identity.', 'x-fern-sdk-group-name': ['auth'], diff --git a/packages/trueforge/src/schemas/auth.ts b/packages/trueforge/src/schemas/auth.ts index 2d354a675..2643b15e3 100644 --- a/packages/trueforge/src/schemas/auth.ts +++ b/packages/trueforge/src/schemas/auth.ts @@ -28,26 +28,24 @@ export const OAuthCallbackSuccessSchema = z.object({ success: z.literal(true).describe('Present when the OAuth callback completed without a return_to.'), }); -/** Wire copy of identity `SubjectType` — kept local so OpenAPI uses `@hono/zod-openapi`. */ -const GetMeSubjectTypeSchema = z - .enum(['user', 'virtualaccount']) - .describe('Subject kind: interactive user or virtual account.'); - export const GetMeSubjectSchema = z .object({ id: z.string().describe('Stable subject identifier for the caller.'), - type: GetMeSubjectTypeSchema, + type: z.string().describe('Subject kind as returned by the identity provider (stored as-is).'), display_name: z.string().describe('Human-readable name for the caller.'), }) .openapi('GetMeSubject'); -export const GetMeResponseSchema = z +export const MeSchema = z .object({ tenant_id: z.string().describe('Tenant scope for the authenticated caller.'), subject: GetMeSubjectSchema, - is_admin: z.boolean().describe('Whether the caller has admin privileges.'), + roles: z.array(z.string()).describe('Roles for the authenticated caller.'), }) - .openapi('GetMeResponse'); + .openapi('Me'); + +export const GetMeResponseSchema = z.object({ data: MeSchema }).openapi('GetMeResponse'); export type GetMeSubject = z.infer; +export type Me = z.infer; export type GetMeResponse = z.infer; diff --git a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts index 96f48562c..b3aec6caf 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts @@ -16,6 +16,7 @@ const MCP_SERVERS_PAGE_SIZE = 100; /** * Fields required to build RequestContext from ServiceFoundry `GET /v1/session`. * Wire shape is camelCase (Nest Session + exposed `subject()`). + * Unauthenticated callers get HTTP 200 with `user: null`. */ const SessionSubjectSchema = z.object({ subjectId: z.string().min(1), @@ -24,15 +25,20 @@ const SessionSubjectSchema = z.object({ subjectSlug: z.string().nullable().optional(), }); -const GetSessionResponseSchema = z.object({ - user: z.object({ - tenantName: z.string().min(1), - roles: z.array(z.string()), - subject: SessionSubjectSchema, - }), +const GetSessionUserSchema = z.object({ + tenantName: z.string().min(1), + roles: z.array(z.string()), + subject: SessionSubjectSchema, +}); + +const GetSessionWireSchema = z.object({ + user: GetSessionUserSchema.nullable(), }); -export type GetSessionResponse = z.infer; +/** Authenticated session payload (`user` is non-null after {@link TrueFoundryServiceFoundryServerClient.getSession}). */ +export type GetSessionResponse = { + user: z.infer; +}; const ListResponseSchema = z.union([ z.array(z.unknown()), @@ -161,18 +167,32 @@ export class TrueFoundryServiceFoundryServerClient { /** * `GET v1/session` for RequestContext mapping. - * Transport / auth failures follow {@link #getJson}; schema mismatch → 502 with `{ cause }`. + * `user: null` (invalid/missing auth on a 200) → 401; all other failures → 500. */ async getSession(accessToken: string): Promise { - const payload = await this.#getJson(this.#url(SESSION_PATH), accessToken); - const parsed = GetSessionResponseSchema.safeParse(payload); + let payload: unknown; + try { + payload = await this.#getJson(this.#url(SESSION_PATH), accessToken); + } catch (error) { + if (error instanceof HTTPException && (error.status === 401 || error.status === 403)) { + throw error; + } + throw new HTTPException(500, { + message: 'TrueFoundry ServiceFoundry session request failed', + cause: error, + }); + } + const parsed = GetSessionWireSchema.safeParse(payload); if (!parsed.success) { throw new HTTPException(500, { message: 'TrueFoundry ServiceFoundry session response was malformed', cause: parsed.error, }); } - return parsed.data; + if (parsed.data.user === null) { + throw new HTTPException(401, { message: 'Authentication required' }); + } + return { user: parsed.data.user }; } #parseListResponse(payload: unknown): ListResponse { diff --git a/packages/trueforge/tests/unit/apis/auth.test.ts b/packages/trueforge/tests/unit/apis/auth.test.ts index a84eb520e..ca2ee10ba 100644 --- a/packages/trueforge/tests/unit/apis/auth.test.ts +++ b/packages/trueforge/tests/unit/apis/auth.test.ts @@ -3,8 +3,8 @@ import { createHash } from 'node:crypto'; import type { Configuration } from 'openid-client'; import winston from 'winston'; import { createAuthRouter } from '../../../src/apis/auth'; -import { createAuthMiddleware } from '../../../src/auth/authenticator'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; +import { createAuthMiddleware } from '../../../src/auth/middleware'; import { disableOidcAuth, initOidc } from '../../../src/auth/oidc'; import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; @@ -104,9 +104,11 @@ describe('auth router (no identity provider configured)', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ - tenant_id: STANDALONE_REQUEST_CONTEXT.tenant_id, - subject: STANDALONE_REQUEST_CONTEXT.subject, - is_admin: STANDALONE_REQUEST_CONTEXT.is_admin, + data: { + tenant_id: STANDALONE_REQUEST_CONTEXT.tenant_id, + subject: STANDALONE_REQUEST_CONTEXT.subject, + roles: STANDALONE_REQUEST_CONTEXT.roles, + }, }); }); }); @@ -477,9 +479,11 @@ describe('auth router (auth enabled)', () => { }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ - tenant_id: 'default', - subject: { id: 'user-1', type: 'user', display_name: 'user-1' }, - is_admin: false, + data: { + tenant_id: 'default', + subject: { id: 'user-1', type: 'user', display_name: 'user-1' }, + roles: [], + }, }); }); }); diff --git a/packages/trueforge/tests/unit/apis/capabilities.test.ts b/packages/trueforge/tests/unit/apis/capabilities.test.ts index 68c312569..2406d378d 100644 --- a/packages/trueforge/tests/unit/apis/capabilities.test.ts +++ b/packages/trueforge/tests/unit/apis/capabilities.test.ts @@ -7,8 +7,8 @@ import type { Configuration } from 'openid-client'; import { createLogger } from 'winston'; import { createCapabilitiesRouter } from '../../../src/apis/capabilities'; import type { Authenticator } from '../../../src/auth/authenticator'; -import { createAuthMiddleware } from '../../../src/auth/authenticator'; import { resolveRequestContext } from '../../../src/auth/identity'; +import { createAuthMiddleware } from '../../../src/auth/middleware'; import { disableOidcAuth, enableOidcAuth, initOidc } from '../../../src/auth/oidc'; import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; diff --git a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts index a02d369c5..58b09090a 100644 --- a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts @@ -236,7 +236,7 @@ describe('MCP OAuth authorize + callback', () => { resolveRequestContext: () => ({ tenant_id: 'default', subject: { id: 'other-user', type: 'user', display_name: 'other-user' }, - is_admin: false, + roles: [], user_credential: null, }), }); diff --git a/packages/trueforge/tests/unit/apis/mcpServers.test.ts b/packages/trueforge/tests/unit/apis/mcpServers.test.ts index 69b329f7f..85589316b 100644 --- a/packages/trueforge/tests/unit/apis/mcpServers.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpServers.test.ts @@ -620,7 +620,7 @@ describe('mcp-servers routers', () => { resolveRequestContext: () => ({ tenant_id: 'default', subject: { id: 'other-user', type: 'user', display_name: 'other-user' }, - is_admin: false, + roles: [], user_credential: null, }), }); diff --git a/packages/trueforge/tests/unit/apis/schedules.test.ts b/packages/trueforge/tests/unit/apis/schedules.test.ts index 0900f9933..3d44e81ff 100644 --- a/packages/trueforge/tests/unit/apis/schedules.test.ts +++ b/packages/trueforge/tests/unit/apis/schedules.test.ts @@ -25,19 +25,19 @@ const mockedStartScheduleRun = jest.mocked(startScheduleRun); const ALICE: RequestContext = { tenant_id: 'default', subject: { id: 'alice', type: 'user', display_name: 'alice' }, - is_admin: false, + roles: [], user_credential: null, }; const BOB: RequestContext = { tenant_id: 'default', subject: { id: 'bob', type: 'user', display_name: 'bob' }, - is_admin: false, + roles: [], user_credential: null, }; const ADMIN: RequestContext = { tenant_id: 'default', subject: { id: 'root', type: 'user', display_name: 'root' }, - is_admin: true, + roles: ['admin'], user_credential: null, }; diff --git a/packages/trueforge/tests/unit/auth/claims.test.ts b/packages/trueforge/tests/unit/auth/claims.test.ts index e453b7fad..4a50b63ef 100644 --- a/packages/trueforge/tests/unit/auth/claims.test.ts +++ b/packages/trueforge/tests/unit/auth/claims.test.ts @@ -23,7 +23,7 @@ function config(overrides: Partial = {}): OIDCConfig { }; } -const TEST_AUTHORIZATION = 'Bearer test'; +const TEST_TOKEN = 'test'; describe('claimValues', () => { it('passes through an array of strings', () => { @@ -96,18 +96,18 @@ describe('resolveRole', () => { }); describe('toRequestContext', () => { - it('maps claims onto RequestContext with admin from the role claim', () => { + it('maps claims onto RequestContext with roles from the role claim', () => { expect( toRequestContext({ claims: { sub: 'user-123', groups: ['harness-admins'], name: 'User One' }, config: config(), - authorization: TEST_AUTHORIZATION, + user_credential: TEST_TOKEN, }), ).toEqual({ tenant_id: 'default', subject: { id: 'user-123', type: 'user', display_name: 'User One' }, - is_admin: true, - user_credential: { authorization: TEST_AUTHORIZATION }, + roles: ['harness-admins'], + user_credential: TEST_TOKEN, }); }); @@ -116,13 +116,13 @@ describe('toRequestContext', () => { toRequestContext({ claims: { sub: 'user-123', groups: ['harness-admins'] }, config: config(), - authorization: TEST_AUTHORIZATION, + user_credential: TEST_TOKEN, }), ).toEqual({ tenant_id: 'default', subject: { id: 'user-123', type: 'user', display_name: 'user-123' }, - is_admin: true, - user_credential: { authorization: TEST_AUTHORIZATION }, + roles: ['harness-admins'], + user_credential: TEST_TOKEN, }); }); @@ -131,7 +131,7 @@ describe('toRequestContext', () => { toRequestContext({ claims: { groups: ['harness-admins'] }, config: config(), - authorization: TEST_AUTHORIZATION, + user_credential: TEST_TOKEN, }), ).toThrow(); }); @@ -141,13 +141,13 @@ describe('toRequestContext', () => { toRequestContext({ claims: { sub: 'user-123', groups: [], email: 'anyone@elsewhere.com' }, config: config({ OIDC_ALLOWED_EMAILS: [] }), - authorization: TEST_AUTHORIZATION, + user_credential: TEST_TOKEN, }), ).toEqual({ tenant_id: 'default', subject: { id: 'user-123', type: 'user', display_name: 'user-123' }, - is_admin: false, - user_credential: { authorization: TEST_AUTHORIZATION }, + roles: [], + user_credential: TEST_TOKEN, }); }); @@ -156,7 +156,7 @@ describe('toRequestContext', () => { toRequestContext({ claims: { sub: 'user-123', groups: [], email: 'outsider@elsewhere.com' }, config: config({ OIDC_ALLOWED_EMAILS: ['*@company.com'] }), - authorization: TEST_AUTHORIZATION, + user_credential: TEST_TOKEN, }), ).toThrow(EmailNotAllowedError); }); @@ -166,13 +166,13 @@ describe('toRequestContext', () => { toRequestContext({ claims: { sub: 'user-123', groups: ['harness-admins'], email: 'alice@company.com' }, config: config({ OIDC_ALLOWED_EMAILS: ['*@company.com'] }), - authorization: TEST_AUTHORIZATION, + user_credential: TEST_TOKEN, }), ).toEqual({ tenant_id: 'default', subject: { id: 'user-123', type: 'user', display_name: 'user-123' }, - is_admin: true, - user_credential: { authorization: TEST_AUTHORIZATION }, + roles: ['harness-admins'], + user_credential: TEST_TOKEN, }); }); }); diff --git a/packages/trueforge/tests/unit/auth/identity.test.ts b/packages/trueforge/tests/unit/auth/identity.test.ts index ff07d2595..70401b94b 100644 --- a/packages/trueforge/tests/unit/auth/identity.test.ts +++ b/packages/trueforge/tests/unit/auth/identity.test.ts @@ -8,9 +8,9 @@ describe('STANDALONE_REQUEST_CONTEXT', () => { subject: { id: 'trueforge-default', type: 'user', - display_name: 'Admin', + display_name: 'trueforge-default', }, - is_admin: true, + roles: ['admin'], user_credential: null, }); }); diff --git a/packages/trueforge/tests/unit/auth/middleware.test.ts b/packages/trueforge/tests/unit/auth/middleware.test.ts index bd517fc31..296dedce4 100644 --- a/packages/trueforge/tests/unit/auth/middleware.test.ts +++ b/packages/trueforge/tests/unit/auth/middleware.test.ts @@ -3,8 +3,8 @@ import { HTTPException } from 'hono/http-exception'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import type { Configuration } from 'openid-client'; import type { Authenticator } from '../../../src/auth/authenticator'; -import { createAdminAuthMiddleware, createAuthMiddleware } from '../../../src/auth/authenticator'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; +import { createAdminAuthMiddleware, createAuthMiddleware } from '../../../src/auth/middleware'; import { disableOidcAuth, enableOidcAuth, initOidc } from '../../../src/auth/oidc'; import { OidcAuthenticator } from '../../../src/auth/oidcAuthenticator'; import { StandaloneAuthenticator } from '../../../src/auth/standaloneAuthenticator'; @@ -156,8 +156,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { function oidcRequestContext(params: { subjectId: string; - isAdmin: boolean; - authorization: string; + roles: string[]; + userCredential: string; displayName?: string; }) { return { @@ -167,8 +167,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { type: 'user' as const, display_name: params.displayName ?? params.subjectId, }, - is_admin: params.isAdmin, - user_credential: { authorization: params.authorization }, + roles: params.roles, + user_credential: params.userCredential, }; } @@ -231,8 +231,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { ok: true, user: oidcRequestContext({ subjectId: 'alice', - isAdmin: true, - authorization: `Bearer ${token}`, + roles: ['admin'], + userCredential: token, }), }); }); @@ -247,8 +247,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { ok: true, user: oidcRequestContext({ subjectId: 'alice', - isAdmin: true, - authorization: `Bearer ${token}`, + roles: ['admin'], + userCredential: token, }), }); }); @@ -267,8 +267,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { ok: true, user: oidcRequestContext({ subjectId: 'bearer-user', - isAdmin: true, - authorization: `Bearer ${bearerToken}`, + roles: ['admin'], + userCredential: bearerToken, }), }); }); @@ -286,8 +286,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { ok: true, user: oidcRequestContext({ subjectId: 'alice', - isAdmin: true, - authorization: `Bearer ${token}`, + roles: ['admin'], + userCredential: token, }), }); }); @@ -302,8 +302,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { ok: true, user: oidcRequestContext({ subjectId: 'alice', - isAdmin: true, - authorization: `Bearer ${token}`, + roles: ['admin'], + userCredential: token, }), }); }); @@ -318,8 +318,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { ok: true, user: oidcRequestContext({ subjectId: 'alice', - isAdmin: true, - authorization: `Bearer ${token}`, + roles: ['admin'], + userCredential: token, }), }); }); @@ -360,8 +360,8 @@ describe('createAuthMiddleware / createAdminAuthMiddleware', () => { ok: true, user: oidcRequestContext({ subjectId: 'alice', - isAdmin: true, - authorization: `Bearer ${token}`, + roles: ['admin'], + userCredential: token, }), }); }); From 72ce8bf835262ba4d1f93d9ada5e9bab534748c7 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Thu, 3 Sep 2026 09:25:11 +0000 Subject: [PATCH 5/9] Regenerate OpenAPI document and TypeScript SDK --- .github/fern/openapi/openapi.json | 48 +++++++++++-------- docs/openapi.json | 48 +++++++++++-------- packages/trueforge-sdk/reference.md | 2 +- .../src/api/resources/auth/client/Client.ts | 2 +- .../src/api/types/GetMeResponse.ts | 6 +-- .../src/api/types/GetMeSubject.ts | 6 +-- .../src/api/types/GetMeSubjectType.ts | 8 ---- packages/trueforge-sdk/src/api/types/Me.ts | 11 +++++ packages/trueforge-sdk/src/api/types/index.ts | 2 +- .../src/serialization/types/GetMeResponse.ts | 10 ++-- .../src/serialization/types/GetMeSubject.ts | 5 +- .../serialization/types/GetMeSubjectType.ts | 12 ----- .../src/serialization/types/Me.ts | 20 ++++++++ .../src/serialization/types/index.ts | 2 +- .../trueforge-sdk/tests/wire/auth.test.ts | 22 +++++---- 15 files changed, 114 insertions(+), 90 deletions(-) delete mode 100644 packages/trueforge-sdk/src/api/types/GetMeSubjectType.ts create mode 100644 packages/trueforge-sdk/src/api/types/Me.ts delete mode 100644 packages/trueforge-sdk/src/serialization/types/GetMeSubjectType.ts create mode 100644 packages/trueforge-sdk/src/serialization/types/Me.ts diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index f36d2d106..ee90c2841 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -1342,22 +1342,12 @@ }, "GetMeResponse": { "properties": { - "is_admin": { - "description": "Whether the caller has admin privileges.", - "type": "boolean" - }, - "subject": { - "$ref": "#/components/schemas/GetMeSubject" - }, - "tenant_id": { - "description": "Tenant scope for the authenticated caller.", - "type": "string" + "data": { + "$ref": "#/components/schemas/Me" } }, "required": [ - "tenant_id", - "subject", - "is_admin" + "data" ], "type": "object" }, @@ -1372,11 +1362,7 @@ "type": "string" }, "type": { - "description": "Subject kind: interactive user or virtual account.", - "enum": [ - "user", - "virtualaccount" - ], + "description": "Subject kind as returned by the identity provider (stored as-is).", "type": "string" } }, @@ -2264,6 +2250,30 @@ ], "type": "object" }, + "Me": { + "properties": { + "roles": { + "description": "Roles for the authenticated caller.", + "items": { + "type": "string" + }, + "type": "array" + }, + "subject": { + "$ref": "#/components/schemas/GetMeSubject" + }, + "tenant_id": { + "description": "Tenant scope for the authenticated caller.", + "type": "string" + } + }, + "required": [ + "tenant_id", + "subject", + "roles" + ], + "type": "object" + }, "MetricsUnit": { "enum": [ "count", @@ -5550,7 +5560,7 @@ }, "/api/v1/auth/me": { "get": { - "description": "Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", + "description": "Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", "responses": { "200": { "content": { diff --git a/docs/openapi.json b/docs/openapi.json index f36d2d106..ee90c2841 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1342,22 +1342,12 @@ }, "GetMeResponse": { "properties": { - "is_admin": { - "description": "Whether the caller has admin privileges.", - "type": "boolean" - }, - "subject": { - "$ref": "#/components/schemas/GetMeSubject" - }, - "tenant_id": { - "description": "Tenant scope for the authenticated caller.", - "type": "string" + "data": { + "$ref": "#/components/schemas/Me" } }, "required": [ - "tenant_id", - "subject", - "is_admin" + "data" ], "type": "object" }, @@ -1372,11 +1362,7 @@ "type": "string" }, "type": { - "description": "Subject kind: interactive user or virtual account.", - "enum": [ - "user", - "virtualaccount" - ], + "description": "Subject kind as returned by the identity provider (stored as-is).", "type": "string" } }, @@ -2264,6 +2250,30 @@ ], "type": "object" }, + "Me": { + "properties": { + "roles": { + "description": "Roles for the authenticated caller.", + "items": { + "type": "string" + }, + "type": "array" + }, + "subject": { + "$ref": "#/components/schemas/GetMeSubject" + }, + "tenant_id": { + "description": "Tenant scope for the authenticated caller.", + "type": "string" + } + }, + "required": [ + "tenant_id", + "subject", + "roles" + ], + "type": "object" + }, "MetricsUnit": { "enum": [ "count", @@ -5550,7 +5560,7 @@ }, "/api/v1/auth/me": { "get": { - "description": "Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", + "description": "Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", "responses": { "200": { "content": { diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index d1640552e..900592723 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -341,7 +341,7 @@ await client.agents.delete("agent_id");
-Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity. +Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.
diff --git a/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts b/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts index 211196a6d..a66ebdbb9 100644 --- a/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts @@ -23,7 +23,7 @@ export class AuthClient { } /** - * Returns the authenticated caller identity (`tenant_id`, `subject`, `is_admin`). When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity. + * Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity. * * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration. * diff --git a/packages/trueforge-sdk/src/api/types/GetMeResponse.ts b/packages/trueforge-sdk/src/api/types/GetMeResponse.ts index ba26c2ac1..b44c06271 100644 --- a/packages/trueforge-sdk/src/api/types/GetMeResponse.ts +++ b/packages/trueforge-sdk/src/api/types/GetMeResponse.ts @@ -3,9 +3,5 @@ import type * as TrueForge from "../index.js"; export interface GetMeResponse { - /** Whether the caller has admin privileges. */ - isAdmin: boolean; - subject: TrueForge.GetMeSubject; - /** Tenant scope for the authenticated caller. */ - tenantId: string; + data: TrueForge.Me; } diff --git a/packages/trueforge-sdk/src/api/types/GetMeSubject.ts b/packages/trueforge-sdk/src/api/types/GetMeSubject.ts index a333d1cac..31c352947 100644 --- a/packages/trueforge-sdk/src/api/types/GetMeSubject.ts +++ b/packages/trueforge-sdk/src/api/types/GetMeSubject.ts @@ -1,12 +1,10 @@ // This file was auto-generated by Fern from our API Definition. -import type * as TrueForge from "../index.js"; - export interface GetMeSubject { /** Human-readable name for the caller. */ displayName: string; /** Stable subject identifier for the caller. */ id: string; - /** Subject kind: interactive user or virtual account. */ - type: TrueForge.GetMeSubjectType; + /** Subject kind as returned by the identity provider (stored as-is). */ + type: string; } diff --git a/packages/trueforge-sdk/src/api/types/GetMeSubjectType.ts b/packages/trueforge-sdk/src/api/types/GetMeSubjectType.ts deleted file mode 100644 index c5d322f5e..000000000 --- a/packages/trueforge-sdk/src/api/types/GetMeSubjectType.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -/** Subject kind: interactive user or virtual account. */ -export const GetMeSubjectType = { - User: "user", - Virtualaccount: "virtualaccount", -} as const; -export type GetMeSubjectType = (typeof GetMeSubjectType)[keyof typeof GetMeSubjectType]; diff --git a/packages/trueforge-sdk/src/api/types/Me.ts b/packages/trueforge-sdk/src/api/types/Me.ts new file mode 100644 index 000000000..687ed4188 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/Me.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface Me { + /** Roles for the authenticated caller. */ + roles: string[]; + subject: TrueForge.GetMeSubject; + /** Tenant scope for the authenticated caller. */ + tenantId: string; +} diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index 82d5c4dbb..cdf422efe 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -62,7 +62,6 @@ export * from "./GetMcpServerCatalogResponse.js"; export * from "./GetMcpServerResponse.js"; export * from "./GetMeResponse.js"; export * from "./GetMeSubject.js"; -export * from "./GetMeSubjectType.js"; export * from "./GetModelProviderCatalogResponse.js"; export * from "./GetModelProviderResponse.js"; export * from "./GetSandboxProviderCatalogResponse.js"; @@ -114,6 +113,7 @@ export * from "./McpServerManifestAuth.js"; export * from "./McpServerToolSelector.js"; export * from "./McpServerType.js"; export * from "./McpToolInfo.js"; +export * from "./Me.js"; export * from "./MetricsUnit.js"; export * from "./Model.js"; export * from "./ModelMessageDeltaEvent.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/GetMeResponse.ts b/packages/trueforge-sdk/src/serialization/types/GetMeResponse.ts index 630f0973b..93b608ee8 100644 --- a/packages/trueforge-sdk/src/serialization/types/GetMeResponse.ts +++ b/packages/trueforge-sdk/src/serialization/types/GetMeResponse.ts @@ -3,19 +3,15 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; -import { GetMeSubject } from "./GetMeSubject.js"; +import { Me } from "./Me.js"; export const GetMeResponse: core.serialization.ObjectSchema = core.serialization.object({ - isAdmin: core.serialization.property("is_admin", core.serialization.boolean()), - subject: GetMeSubject, - tenantId: core.serialization.property("tenant_id", core.serialization.string()), + data: Me, }); export declare namespace GetMeResponse { export interface Raw { - is_admin: boolean; - subject: GetMeSubject.Raw; - tenant_id: string; + data: Me.Raw; } } diff --git a/packages/trueforge-sdk/src/serialization/types/GetMeSubject.ts b/packages/trueforge-sdk/src/serialization/types/GetMeSubject.ts index 9be6e10f4..280d57393 100644 --- a/packages/trueforge-sdk/src/serialization/types/GetMeSubject.ts +++ b/packages/trueforge-sdk/src/serialization/types/GetMeSubject.ts @@ -3,19 +3,18 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; -import { GetMeSubjectType } from "./GetMeSubjectType.js"; export const GetMeSubject: core.serialization.ObjectSchema = core.serialization.object({ displayName: core.serialization.property("display_name", core.serialization.string()), id: core.serialization.string(), - type: GetMeSubjectType, + type: core.serialization.string(), }); export declare namespace GetMeSubject { export interface Raw { display_name: string; id: string; - type: GetMeSubjectType.Raw; + type: string; } } diff --git a/packages/trueforge-sdk/src/serialization/types/GetMeSubjectType.ts b/packages/trueforge-sdk/src/serialization/types/GetMeSubjectType.ts deleted file mode 100644 index ab766415a..000000000 --- a/packages/trueforge-sdk/src/serialization/types/GetMeSubjectType.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as TrueForge from "../../api/index.js"; -import * as core from "../../core/index.js"; -import type * as serializers from "../index.js"; - -export const GetMeSubjectType: core.serialization.Schema = - core.serialization.enum_(["user", "virtualaccount"]); - -export declare namespace GetMeSubjectType { - export type Raw = "user" | "virtualaccount"; -} diff --git a/packages/trueforge-sdk/src/serialization/types/Me.ts b/packages/trueforge-sdk/src/serialization/types/Me.ts new file mode 100644 index 000000000..eef111dca --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/Me.ts @@ -0,0 +1,20 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { GetMeSubject } from "./GetMeSubject.js"; + +export const Me: core.serialization.ObjectSchema = core.serialization.object({ + roles: core.serialization.list(core.serialization.string()), + subject: GetMeSubject, + tenantId: core.serialization.property("tenant_id", core.serialization.string()), +}); + +export declare namespace Me { + export interface Raw { + roles: string[]; + subject: GetMeSubject.Raw; + tenant_id: string; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index 82d5c4dbb..cdf422efe 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -62,7 +62,6 @@ export * from "./GetMcpServerCatalogResponse.js"; export * from "./GetMcpServerResponse.js"; export * from "./GetMeResponse.js"; export * from "./GetMeSubject.js"; -export * from "./GetMeSubjectType.js"; export * from "./GetModelProviderCatalogResponse.js"; export * from "./GetModelProviderResponse.js"; export * from "./GetSandboxProviderCatalogResponse.js"; @@ -114,6 +113,7 @@ export * from "./McpServerManifestAuth.js"; export * from "./McpServerToolSelector.js"; export * from "./McpServerType.js"; export * from "./McpToolInfo.js"; +export * from "./Me.js"; export * from "./MetricsUnit.js"; export * from "./Model.js"; export * from "./ModelMessageDeltaEvent.js"; diff --git a/packages/trueforge-sdk/tests/wire/auth.test.ts b/packages/trueforge-sdk/tests/wire/auth.test.ts index 5d5032c83..e4d722c1f 100644 --- a/packages/trueforge-sdk/tests/wire/auth.test.ts +++ b/packages/trueforge-sdk/tests/wire/auth.test.ts @@ -10,22 +10,26 @@ describe("AuthClient", () => { const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); const rawResponseBody = { - is_admin: true, - subject: { display_name: "display_name", id: "id", type: "user" }, - tenant_id: "tenant_id", + data: { + roles: ["roles"], + subject: { display_name: "display_name", id: "id", type: "type" }, + tenant_id: "tenant_id", + }, }; server.mockEndpoint().get("/api/v1/auth/me").respondWith().statusCode(200).jsonBody(rawResponseBody).build(); const response = await client.auth.me(); expect(response).toEqual({ - isAdmin: true, - subject: { - displayName: "display_name", - id: "id", - type: "user", + data: { + roles: ["roles"], + subject: { + displayName: "display_name", + id: "id", + type: "type", + }, + tenantId: "tenant_id", }, - tenantId: "tenant_id", }); }); From 44bc97b874c7b8f3a3c2c98389451b944a36dbb5 Mon Sep 17 00:00:00 2001 From: thesujai Date: Thu, 3 Sep 2026 14:56:39 +0530 Subject: [PATCH 6/9] refactor: Change RequestSubject from type to interface and update GetSessionResponse to interface for consistency --- packages/trueforge/src/auth/identity.ts | 4 ++-- .../src/truefoundry/TrueFoundryServiceFoundryServerClient.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/trueforge/src/auth/identity.ts b/packages/trueforge/src/auth/identity.ts index b13ad5f6e..79645f855 100644 --- a/packages/trueforge/src/auth/identity.ts +++ b/packages/trueforge/src/auth/identity.ts @@ -1,10 +1,10 @@ import type { Context } from 'hono'; -export type RequestSubject = { +export interface RequestSubject { id: string; type: string; display_name: string; -}; +} export interface RequestContext { tenant_id: string; diff --git a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts index b3aec6caf..047aabed6 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts @@ -36,9 +36,9 @@ const GetSessionWireSchema = z.object({ }); /** Authenticated session payload (`user` is non-null after {@link TrueFoundryServiceFoundryServerClient.getSession}). */ -export type GetSessionResponse = { +export interface GetSessionResponse { user: z.infer; -}; +} const ListResponseSchema = z.union([ z.array(z.unknown()), From 30ee32bf1b0d458debdf5439824fde28c4b9d081 Mon Sep 17 00:00:00 2001 From: thesujai Date: Thu, 3 Sep 2026 15:06:56 +0530 Subject: [PATCH 7/9] feat: Enhance authentication response to include session type, unifying output across standalone, OIDC, and TrueFoundry modes --- .changeset/unified-request-context-auth.md | 2 +- packages/trueforge/src/apis/auth.ts | 2 ++ packages/trueforge/src/routes/authRoutes.ts | 3 ++- packages/trueforge/src/schemas/auth.ts | 9 +++++++++ packages/trueforge/tests/unit/apis/auth.test.ts | 2 ++ 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.changeset/unified-request-context-auth.md b/.changeset/unified-request-context-auth.md index 990bc4327..df05ca6e8 100644 --- a/.changeset/unified-request-context-auth.md +++ b/.changeset/unified-request-context-auth.md @@ -2,4 +2,4 @@ '@truefoundry/trueforge': minor --- -Unify request-scoped RequestContext across standalone, OIDC, and TrueFoundry auth. `/auth/me` returns `{ data: { tenant_id, subject, roles } }` (OpenAPI/SDK regen deferred to CI). +Unify request-scoped RequestContext across standalone, OIDC, and TrueFoundry auth. `/auth/me` returns `{ data: { type, tenant_id, subject, roles } }` (`type` is `oidc-connected` | `default`; OpenAPI/SDK regen deferred to CI). diff --git a/packages/trueforge/src/apis/auth.ts b/packages/trueforge/src/apis/auth.ts index 1b6bcc93a..c7a89c599 100644 --- a/packages/trueforge/src/apis/auth.ts +++ b/packages/trueforge/src/apis/auth.ts @@ -124,6 +124,8 @@ export function createAuthRouter(params: { const requestContext = resolveRequestContext(c); const body: GetMeResponse = { data: { + // FE logout chrome keys off `oidc-connected` (browser SSO cookie session). + type: params.oidcClient !== undefined ? 'oidc-connected' : 'default', tenant_id: requestContext.tenant_id, subject: requestContext.subject, roles: requestContext.roles, diff --git a/packages/trueforge/src/routes/authRoutes.ts b/packages/trueforge/src/routes/authRoutes.ts index 837a2f1c8..dc1314a02 100644 --- a/packages/trueforge/src/routes/authRoutes.ts +++ b/packages/trueforge/src/routes/authRoutes.ts @@ -63,7 +63,8 @@ export const meRoute = createRoute({ tags: [OpenApiTag.AUTH], summary: 'Current session', description: - 'Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled ' + + 'Returns the authenticated caller identity (`type`, `tenant_id`, `subject`, `roles`) wrapped as `{ data }`. ' + + '`type` is `oidc-connected` when browser OIDC is enabled, otherwise `default`. When auth is enabled ' + 'this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is ' + 'disabled, returns the standalone default identity.', 'x-fern-sdk-group-name': ['auth'], diff --git a/packages/trueforge/src/schemas/auth.ts b/packages/trueforge/src/schemas/auth.ts index 2643b15e3..58a4be218 100644 --- a/packages/trueforge/src/schemas/auth.ts +++ b/packages/trueforge/src/schemas/auth.ts @@ -36,8 +36,16 @@ export const GetMeSubjectSchema = z }) .openapi('GetMeSubject'); +export const MeSessionTypeSchema = z + .enum(['default', 'oidc-connected']) + .describe( + '`oidc-connected` when the process is running with browser OIDC login; `default` for standalone or TrueFoundry token auth.', + ) + .openapi('MeSessionType'); + export const MeSchema = z .object({ + type: MeSessionTypeSchema, tenant_id: z.string().describe('Tenant scope for the authenticated caller.'), subject: GetMeSubjectSchema, roles: z.array(z.string()).describe('Roles for the authenticated caller.'), @@ -47,5 +55,6 @@ export const MeSchema = z export const GetMeResponseSchema = z.object({ data: MeSchema }).openapi('GetMeResponse'); export type GetMeSubject = z.infer; +export type MeSessionType = z.infer; export type Me = z.infer; export type GetMeResponse = z.infer; diff --git a/packages/trueforge/tests/unit/apis/auth.test.ts b/packages/trueforge/tests/unit/apis/auth.test.ts index ca2ee10ba..8f607ca21 100644 --- a/packages/trueforge/tests/unit/apis/auth.test.ts +++ b/packages/trueforge/tests/unit/apis/auth.test.ts @@ -105,6 +105,7 @@ describe('auth router (no identity provider configured)', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ data: { + type: 'default', tenant_id: STANDALONE_REQUEST_CONTEXT.tenant_id, subject: STANDALONE_REQUEST_CONTEXT.subject, roles: STANDALONE_REQUEST_CONTEXT.roles, @@ -480,6 +481,7 @@ describe('auth router (auth enabled)', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ data: { + type: 'oidc-connected', tenant_id: 'default', subject: { id: 'user-1', type: 'user', display_name: 'user-1' }, roles: [], From 7ac391967c41c55cab72145a7add37fb8a86aada Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Thu, 3 Sep 2026 09:39:05 +0000 Subject: [PATCH 8/9] Regenerate OpenAPI document and TypeScript SDK --- .github/fern/openapi/openapi.json | 14 +++++++++++++- docs/openapi.json | 14 +++++++++++++- packages/trueforge-sdk/reference.md | 2 +- .../src/api/resources/auth/client/Client.ts | 2 +- packages/trueforge-sdk/src/api/types/Me.ts | 1 + .../trueforge-sdk/src/api/types/MeSessionType.ts | 8 ++++++++ packages/trueforge-sdk/src/api/types/index.ts | 1 + .../trueforge-sdk/src/serialization/types/Me.ts | 3 +++ .../src/serialization/types/MeSessionType.ts | 12 ++++++++++++ .../trueforge-sdk/src/serialization/types/index.ts | 1 + packages/trueforge-sdk/tests/wire/auth.test.ts | 2 ++ 11 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 packages/trueforge-sdk/src/api/types/MeSessionType.ts create mode 100644 packages/trueforge-sdk/src/serialization/types/MeSessionType.ts diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index ee90c2841..3df5d5a89 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -2265,15 +2265,27 @@ "tenant_id": { "description": "Tenant scope for the authenticated caller.", "type": "string" + }, + "type": { + "$ref": "#/components/schemas/MeSessionType" } }, "required": [ + "type", "tenant_id", "subject", "roles" ], "type": "object" }, + "MeSessionType": { + "description": "`oidc-connected` when the process is running with browser OIDC login; `default` for standalone or TrueFoundry token auth.", + "enum": [ + "default", + "oidc-connected" + ], + "type": "string" + }, "MetricsUnit": { "enum": [ "count", @@ -5560,7 +5572,7 @@ }, "/api/v1/auth/me": { "get": { - "description": "Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", + "description": "Returns the authenticated caller identity (`type`, `tenant_id`, `subject`, `roles`) wrapped as `{ data }`. `type` is `oidc-connected` when browser OIDC is enabled, otherwise `default`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", "responses": { "200": { "content": { diff --git a/docs/openapi.json b/docs/openapi.json index ee90c2841..3df5d5a89 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2265,15 +2265,27 @@ "tenant_id": { "description": "Tenant scope for the authenticated caller.", "type": "string" + }, + "type": { + "$ref": "#/components/schemas/MeSessionType" } }, "required": [ + "type", "tenant_id", "subject", "roles" ], "type": "object" }, + "MeSessionType": { + "description": "`oidc-connected` when the process is running with browser OIDC login; `default` for standalone or TrueFoundry token auth.", + "enum": [ + "default", + "oidc-connected" + ], + "type": "string" + }, "MetricsUnit": { "enum": [ "count", @@ -5560,7 +5572,7 @@ }, "/api/v1/auth/me": { "get": { - "description": "Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", + "description": "Returns the authenticated caller identity (`type`, `tenant_id`, `subject`, `roles`) wrapped as `{ data }`. `type` is `oidc-connected` when browser OIDC is enabled, otherwise `default`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.", "responses": { "200": { "content": { diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 900592723..556d2dc31 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -341,7 +341,7 @@ await client.agents.delete("agent_id");
-Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity. +Returns the authenticated caller identity (`type`, `tenant_id`, `subject`, `roles`) wrapped as `{ data }`. `type` is `oidc-connected` when browser OIDC is enabled, otherwise `default`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity.
diff --git a/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts b/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts index a66ebdbb9..b2575feed 100644 --- a/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/auth/client/Client.ts @@ -23,7 +23,7 @@ export class AuthClient { } /** - * Returns the authenticated caller identity (`tenant_id`, `subject`, `roles`) wrapped as `{ data }`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity. + * Returns the authenticated caller identity (`type`, `tenant_id`, `subject`, `roles`) wrapped as `{ data }`. `type` is `oidc-connected` when browser OIDC is enabled, otherwise `default`. When auth is enabled this requires a valid `id_token` cookie or `Authorization: Bearer` token (401 otherwise). When auth is disabled, returns the standalone default identity. * * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration. * diff --git a/packages/trueforge-sdk/src/api/types/Me.ts b/packages/trueforge-sdk/src/api/types/Me.ts index 687ed4188..d79c8dbd8 100644 --- a/packages/trueforge-sdk/src/api/types/Me.ts +++ b/packages/trueforge-sdk/src/api/types/Me.ts @@ -8,4 +8,5 @@ export interface Me { subject: TrueForge.GetMeSubject; /** Tenant scope for the authenticated caller. */ tenantId: string; + type: TrueForge.MeSessionType; } diff --git a/packages/trueforge-sdk/src/api/types/MeSessionType.ts b/packages/trueforge-sdk/src/api/types/MeSessionType.ts new file mode 100644 index 000000000..5abebac03 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/MeSessionType.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +/** `oidc-connected` when the process is running with browser OIDC login; `default` for standalone or TrueFoundry token auth. */ +export const MeSessionType = { + Default: "default", + OidcConnected: "oidc-connected", +} as const; +export type MeSessionType = (typeof MeSessionType)[keyof typeof MeSessionType]; diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index cdf422efe..124434e09 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -114,6 +114,7 @@ export * from "./McpServerToolSelector.js"; export * from "./McpServerType.js"; export * from "./McpToolInfo.js"; export * from "./Me.js"; +export * from "./MeSessionType.js"; export * from "./MetricsUnit.js"; export * from "./Model.js"; export * from "./ModelMessageDeltaEvent.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/Me.ts b/packages/trueforge-sdk/src/serialization/types/Me.ts index eef111dca..08ec4faf9 100644 --- a/packages/trueforge-sdk/src/serialization/types/Me.ts +++ b/packages/trueforge-sdk/src/serialization/types/Me.ts @@ -4,11 +4,13 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; import { GetMeSubject } from "./GetMeSubject.js"; +import { MeSessionType } from "./MeSessionType.js"; export const Me: core.serialization.ObjectSchema = core.serialization.object({ roles: core.serialization.list(core.serialization.string()), subject: GetMeSubject, tenantId: core.serialization.property("tenant_id", core.serialization.string()), + type: MeSessionType, }); export declare namespace Me { @@ -16,5 +18,6 @@ export declare namespace Me { roles: string[]; subject: GetMeSubject.Raw; tenant_id: string; + type: MeSessionType.Raw; } } diff --git a/packages/trueforge-sdk/src/serialization/types/MeSessionType.ts b/packages/trueforge-sdk/src/serialization/types/MeSessionType.ts new file mode 100644 index 000000000..f42cdabba --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/MeSessionType.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const MeSessionType: core.serialization.Schema = + core.serialization.enum_(["default", "oidc-connected"]); + +export declare namespace MeSessionType { + export type Raw = "default" | "oidc-connected"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index cdf422efe..124434e09 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -114,6 +114,7 @@ export * from "./McpServerToolSelector.js"; export * from "./McpServerType.js"; export * from "./McpToolInfo.js"; export * from "./Me.js"; +export * from "./MeSessionType.js"; export * from "./MetricsUnit.js"; export * from "./Model.js"; export * from "./ModelMessageDeltaEvent.js"; diff --git a/packages/trueforge-sdk/tests/wire/auth.test.ts b/packages/trueforge-sdk/tests/wire/auth.test.ts index e4d722c1f..677841a41 100644 --- a/packages/trueforge-sdk/tests/wire/auth.test.ts +++ b/packages/trueforge-sdk/tests/wire/auth.test.ts @@ -14,6 +14,7 @@ describe("AuthClient", () => { roles: ["roles"], subject: { display_name: "display_name", id: "id", type: "type" }, tenant_id: "tenant_id", + type: "default", }, }; @@ -29,6 +30,7 @@ describe("AuthClient", () => { type: "type", }, tenantId: "tenant_id", + type: "default", }, }); }); From e593de8a5899533aebda22af7816f33c24ba41ea Mon Sep 17 00:00:00 2001 From: thesujai Date: Thu, 3 Sep 2026 15:22:26 +0530 Subject: [PATCH 9/9] fix: Update authentication logic to reference `data.type` for OIDC connection status and enhance README documentation --- packages/frontend/README.md | 3 +- packages/frontend/src/LogoutButton.tsx | 2 +- packages/frontend/src/authSession.ts | 6 ++-- packages/frontend/tests/authSession.test.ts | 9 ++++-- packages/trueforge/src/apis/schedules.ts | 2 +- packages/trueforge/src/auth/identity.ts | 32 +++++++++++++++++-- packages/trueforge/src/auth/middleware.ts | 8 +---- .../tests/unit/auth/identity.test.ts | 11 ++++++- 8 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/frontend/README.md b/packages/frontend/README.md index b1159021b..b37912669 100644 --- a/packages/frontend/README.md +++ b/packages/frontend/README.md @@ -47,7 +47,8 @@ already bound, dev exits instead of picking another. Proxy wiring lives in [`authFetch.ts`](src/authFetch.ts) wraps `fetch` and redirects to OIDC login on HTTP 401. [`authSession.ts`](src/authSession.ts) probes `/me` with a non-redirecting client so the -welcome screen can show before login. Pass the auth-aware `fetch` into +welcome screen can show before login. Logout chrome appears when `me().data.type` is +`oidc-connected`. Pass the auth-aware `fetch` into `server={{ type: 'trueforge', fetch }}` so the built-in Harness adapter shares the same session cookies. diff --git a/packages/frontend/src/LogoutButton.tsx b/packages/frontend/src/LogoutButton.tsx index 2db69ef08..cc83dcedb 100644 --- a/packages/frontend/src/LogoutButton.tsx +++ b/packages/frontend/src/LogoutButton.tsx @@ -6,7 +6,7 @@ import { UI_BASE_PATH } from './publicPath'; /** * Icon button next to Settings (via `ShellActionsActionSlot` override). - * Shown only when `auth.me()` returns `type: "oidc-connected"`. + * Shown only when `auth.me()` returns `data.type: "oidc-connected"`. * Uses a module cache so remounts of the action slot do not hide the control during refetches. */ export function LogoutButton() { diff --git a/packages/frontend/src/authSession.ts b/packages/frontend/src/authSession.ts index 1eb1bb2a7..4d9f73d4c 100644 --- a/packages/frontend/src/authSession.ts +++ b/packages/frontend/src/authSession.ts @@ -37,12 +37,12 @@ export function resetOidcSessionCacheForTests(): void { } /** - * True when the current session is a browser OIDC login (`type: "oidc-connected"`). + * True when the current session is a browser OIDC login (`data.type: "oidc-connected"`). * When auth is enabled, unauthenticated callers get HTTP 401 from `/me`. */ export async function isOidcConnectedSession(client: TrueForge = authClient): Promise { - const session = await client.auth.me(); - const isConnected = session.type === 'oidc-connected'; + const { data } = await client.auth.me(); + const isConnected = data.type === 'oidc-connected'; cachedIsOidcConnected = isConnected; return isConnected; } diff --git a/packages/frontend/tests/authSession.test.ts b/packages/frontend/tests/authSession.test.ts index e9102ac59..7d068e229 100644 --- a/packages/frontend/tests/authSession.test.ts +++ b/packages/frontend/tests/authSession.test.ts @@ -15,9 +15,12 @@ function createClient(params: { type?: 'default' | 'oidc-connected'; meError?: E me: async () => { if (params.meError != null) throw params.meError; return { - type: params.type ?? 'default', - email: 'user@example.com', - role: 'user', + data: { + type: params.type ?? 'default', + tenantId: 'default', + subject: { id: 'user-1', type: 'user', displayName: 'user-1' }, + roles: [], + }, }; }, }, diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index f0ea585bd..5f5b5a13e 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -102,7 +102,7 @@ export function validateManifest(manifest: Pick): boolean { - return requestContext.roles.includes('admin'); + switch (getTrueForgeMode()) { + case TrueForgeMode.TrueFoundry: + // TODO: stop treating `tenant-admin` as TrueForge admin once TFY role mapping is settled. + return requestContext.roles.includes(TRUEFOUNDRY_ADMIN_ROLE); + case TrueForgeMode.Oidc: { + const adminValue = isOidcConfigured(configuration) + ? configuration.OIDC.OIDC_ADMIN_ROLE_VALUE + : STANDALONE_ADMIN_ROLE; + return requestContext.roles.includes(adminValue); + } + case TrueForgeMode.Standalone: + return requestContext.roles.includes(STANDALONE_ADMIN_ROLE); + } } diff --git a/packages/trueforge/src/auth/middleware.ts b/packages/trueforge/src/auth/middleware.ts index 16f25bf4b..64ee2bf65 100644 --- a/packages/trueforge/src/auth/middleware.ts +++ b/packages/trueforge/src/auth/middleware.ts @@ -2,7 +2,6 @@ import type { Context, MiddlewareHandler } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { jwtVerify } from 'jose'; -import { getTrueForgeMode, TrueForgeMode } from '../config'; import type { Authenticator } from './authenticator'; import { toRequestContext, type IdTokenClaims } from './claims'; import { hasAdminRole, type RequestContext } from './identity'; @@ -21,12 +20,7 @@ export function createAuthMiddleware(authenticator: Authenticator): MiddlewareHa export function createAdminAuthMiddleware(authenticator: Authenticator): MiddlewareHandler { return async (c, next) => { const requestContext = await authenticator.authenticate(c); - if (getTrueForgeMode() === TrueForgeMode.TrueFoundry) { - // TODO: stop treating `tenant-admin` as TrueForge admin once TFY role mapping is settled. - if (!requestContext.roles.includes('tenant-admin')) { - throw new HTTPException(403, { message: 'Admin access required' }); - } - } else if (!hasAdminRole(requestContext)) { + if (!hasAdminRole(requestContext)) { throw new HTTPException(403, { message: 'Admin access required' }); } c.set('request_context', requestContext); diff --git a/packages/trueforge/tests/unit/auth/identity.test.ts b/packages/trueforge/tests/unit/auth/identity.test.ts index 70401b94b..2152cf21d 100644 --- a/packages/trueforge/tests/unit/auth/identity.test.ts +++ b/packages/trueforge/tests/unit/auth/identity.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { resolveRequestContext, STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; +import { hasAdminRole, resolveRequestContext, STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; describe('STANDALONE_REQUEST_CONTEXT', () => { it('has the fixed standalone identity shape', () => { @@ -16,6 +16,15 @@ describe('STANDALONE_REQUEST_CONTEXT', () => { }); }); +describe('hasAdminRole', () => { + // Unit tests run under STANDALONE=true (.env.test) → mode Standalone. + it('treats admin as admin and other roles as non-admin in standalone', () => { + expect(hasAdminRole({ roles: ['admin'] })).toBe(true); + expect(hasAdminRole({ roles: ['everyone'] })).toBe(false); + expect(hasAdminRole(STANDALONE_REQUEST_CONTEXT)).toBe(true); + }); +}); + describe('resolveRequestContext', () => { it('returns request_context when set by auth middleware', async () => { const app = new Hono();