Files
0pt/middleware.ts
T
admin d40360aabc
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 7s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m5s
feat(auth): dual-accept CL0UD workload identity on the machine routes
The internal machine routes (`quality/bridge-run`, `canonical/facts`,
`internal/evidence-outbox/dispatch`, `internal/er/candidates`,
`search/definitions`) trusted a shared `X-Internal-Token` with NO tenant
authority: middleware let it through without setting any verified tenant, so
`assertTenantMatchesVerified` was a no-op and a caller-chosen `tenant_id` in
the body was authoritative.

This closes that gap while keeping the engine working during the transition
to CL0UD workload tokens:

- `src/auth/internal-service-tenant.ts` — the server-configured
  `INTERNAL_SERVICE_TENANTS` scope, a scope header that only middleware may
  set, and a parser that strips/ignores client values.
- `src/auth/cl0ud-workload.ts` — a real `jose`/JWKS verifier for CL0UD
  workload JWTs (`iss`/`aud`/`exp`), deriving `tenant_id` and `capabilities`
  from verified claims only. Fail-closed when a token is presented but the
  issuer/JWKS/audience/tenant claim does not verify.
- `middleware.ts` — strips every `x-0pt-verified-*` header on all forwarded
  paths. Legacy internal tokens get the single configured tenant as scope
  (an ambiguous multi-tenant list is refused with 401 instead of widened);
  a Bearer CL0UD workload token is verified and its claims become the
  verified tenant/subject/capabilities.
- `src/http/tenant-guard.ts` — enforces the service scope on tenant-scoped
  writes (deny when the tenant is outside the scope) and adds
  `assertWorkloadCapabilities` for capability-scoped principals.
- `quality/bridge-run` now calls `assertTenantMatchesVerified` like
  `canonical/facts` already did.

Tests: `tests/auth/workload-identity-dual-accept.test.ts` (10 cases) covers
forged-header stripping, ambiguous-scope refusal, wrong audience, missing
tenant claim, cross-tenant denial and missing-capability denial.

Real CL0UD provisioning (service accounts + published JWKS) is still
required before a workload token exists in production; until then the
legacy token path is the only one that authenticates, now tenant-scoped.
2026-11-09 18:34:34 +01:00

160 lines
7.0 KiB
TypeScript

/**
* Next.js auth seam for /api/0pt. Browser/user calls use verified bearer/session.
* Explicit machine routes accept: (1) legacy X-Internal-Token + server-configured
* single-tenant scope, or (2) verified CL0UD workload JWT (tenant/capabilities
* from claims, never client headers). Deny by default.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
import {
SERVICE_CAPABILITIES_HEADER,
SERVICE_TENANT_SCOPE_HEADER,
resolveInternalServiceTenants,
} from '@/src/auth/internal-service-tenant';
import { isCl0udWorkloadConfigured, looksLikeCl0udWorkloadToken, verifyCl0udWorkloadToken } from '@/src/auth/cl0ud-workload';
assertAuthConfigSafe();
export const config = { matcher: ['/api/0pt', '/api/0pt/:path*'] };
const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/ready(\/|$)/, /^\/api\/admin\/metrics(\/|$)/];
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
/^\/api\/0pt\/quality\/bridge-run$/,
/^\/api\/0pt\/canonical\/facts$/,
/^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/,
/^\/api\/0pt\/internal\/er\/candidates$/,
/^\/api\/0pt\/search\/definitions$/,
];
let _authDiagCount = 0;
let internalScopeWarned = false;
/**
* Headers that only middleware may set. Anything a client sends under these
* names is a forgery attempt and is removed before forwarding — including on
* the internal-token path, which previously passed the original header map
* through unchanged.
*/
const VERIFIED_HEADERS = [
'x-0pt-verified-tenant',
'x-0pt-verified-sub',
SERVICE_TENANT_SCOPE_HEADER,
SERVICE_CAPABILITIES_HEADER,
] as const;
function stripVerifiedHeaders(req: NextRequest): Headers {
const forwarded = new Headers(req.headers);
for (const name of VERIFIED_HEADERS) forwarded.delete(name);
return forwarded;
}
function bearerToken(req: NextRequest): string | null {
const h = req.headers.get('authorization');
if (!h?.toLowerCase().startsWith('bearer ')) return null;
return h.slice(7).trim() || null;
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname;
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path))) {
// (1) Legacy shared internal token (engine/n8n). Tenant authority is SERVER
// CONFIG (INTERNAL_SERVICE_TENANTS), never a client header: an inbound
// `X-0pt-Internal-Tenant` is ignored and stripped. With no configured
// tenants the principal has no tenant authority and tenant-scoped writes
// fail closed with 403.
if (verifyInternalServiceToken(req.headers)) {
const forwarded = stripVerifiedHeaders(req);
const tenants = resolveInternalServiceTenants();
if (tenants.length > 1) {
// The shared X-Internal-Token identifies ONE service process, not a set
// of independently authenticated principals. A multi-tenant allowlist
// would let any holder of that shared token choose between all listed
// tenants, which is exactly the client-controlled authority this guard
// exists to remove. Multi-tenant service access requires a verified
// workload-identity binding (CL0UD delegation contract); until that
// exists, an ambiguous configuration fails closed.
console.error(
'[0pt-auth] INTERNAL_SERVICE_TENANTS lists more than one tenant: refusing the internal request until workload-identity binding exists',
);
return NextResponse.json(
{
error: 'OPT_UNAUTHENTICATED',
message:
'internal service tenant scope is ambiguous: configure exactly one tenant until a verified workload-identity binding exists',
},
{ status: 401 },
);
}
forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(','));
if (tenants.length === 1) {
forwarded.set('x-0pt-verified-tenant', tenants[0]);
} else if (tenants.length === 0 && !internalScopeWarned) {
internalScopeWarned = true;
console.warn(
'[0pt-auth] INTERNAL_SERVICE_TENANTS is unset: the internal service principal has no tenant authority; tenant-scoped writes will be denied',
);
}
return NextResponse.next({ request: { headers: forwarded } });
}
// (2) CL0UD workload JWT (dual-accept): tenant and capabilities come from
// verified claims only. Attempted only when the issuer is configured or the
// presented token is issued by it; an invalid one is a 401, never a
// fall-through to an unauthenticated dev path.
const bt = bearerToken(req);
if (bt && (isCl0udWorkloadConfigured() || looksLikeCl0udWorkloadToken(bt))) {
try {
const principal = await verifyCl0udWorkloadToken(bt);
const forwarded = stripVerifiedHeaders(req);
forwarded.set('x-0pt-verified-sub', principal.sub);
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
forwarded.set(SERVICE_CAPABILITIES_HEADER, principal.capabilities.join(','));
return NextResponse.next({ request: { headers: forwarded } });
} catch (err) {
const message = err instanceof Error ? err.message : 'workload token verification failed';
return NextResponse.json({ error: 'OPT_UNAUTHENTICATED', message }, { status: 401 });
}
}
}
if (_authDiagCount < 5) {
_authDiagCount += 1;
console.log(`[0pt-auth] middleware path=${path} auth_header=${!!req.headers.get('authorization')} auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} request#${_authDiagCount}`);
}
if (!req.headers.get('authorization')) {
const session = await verifySession(req.cookies.get(SESSION_COOKIE)?.value);
if (
session &&
typeof session.sub === 'string' &&
typeof session.tenantId === 'string' &&
typeof session.exp === 'number' &&
session.exp > Date.now()
) {
const forwarded = stripVerifiedHeaders(req);
forwarded.set('x-0pt-verified-sub', session.sub);
forwarded.set('x-0pt-verified-tenant', session.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
}
}
const decision = await resolveAuthContext(req.headers);
if (!decision.ok) {
return NextResponse.json({ error: decision.code, message: decision.message, ...(decision.details ? { details: decision.details } : {}) }, { status: decision.status });
}
const principal = decision.ctx.principal;
if (principal) {
const forwarded = stripVerifiedHeaders(req);
forwarded.set('x-0pt-verified-sub', principal.sub);
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
}
// No verified principal (explicit AUTH_ENFORCE=false development mode).
// Never forward a client-named verified header, even in dev.
return NextResponse.next({ request: { headers: stripVerifiedHeaders(req) } });
}