import { resolveFirstPartyDelegationSecret, type Settings } from "@opengeni/config";
import { verifyDelegatedAccessToken } from "@opengeni/contracts";
import type { Context, MiddlewareHandler } from "hono";
import { installExactPaths, isInstallRedirectPath } from "../routes/install";
import {
isMcpOAuthPublicProtocolPath,
isMcpOAuthResourcePath,
mcpOAuthBearerToken,
} from "../mcp-oauth";
const githubConnectPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/connect$/;
const githubInstallationLinkPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/installations$/;
const prReviewGithubBrowserPathPattern =
/^\/v1\/workspaces\/[^/]+\/pr-review\/github\/(?:connect|installations\/select|installations\/[^/]+\/configure)$/;
export function requireAccessKey(settings: Settings): MiddlewareHandler {
return async (c, next) => {
// §7.2 P1: requireAccessKey is the coarse NETWORK perimeter, not the
// per-tenant identity gate (that is resolveAccessContext). When
// `authRequired:false` it is a NO-OP — the embedded (Path 2) case where the
// host's own auth is the sole human gate and OpenGeni is mounted behind it.
// Standalone/separate deployments set `authRequired:true` to keep this ON as
// the shared-deployment-key perimeter.
if (!settings.authRequired || isAuthExempt(c, settings)) {
await next();
return;
}
if (await isAuthorized(c, settings)) {
await next();
return;
}
return c.json({ error: "unauthorized" }, 401);
};
}
function isAuthExempt(c: Context, settings: Settings): boolean {
if (c.req.method === "OPTIONS") {
return true;
}
const path = new URL(c.req.url).pathname;
if (settings.mcpOauthEnabled && isMcpOAuthPublicProtocolPath(path)) {
return true;
}
if (settings.mcpOauthEnabled && isMcpOAuthResourcePath(path) && mcpOAuthBearerToken(c.req.raw)) {
return true;
}
if (path === "/v1/config/client") {
return true;
}
if (path === "/v1/auth" || path.startsWith("/v1/auth/")) {
return true;
}
if (path === "/v1/webhooks/stripe") {
return true;
}
if (c.req.method === "POST" && path.startsWith("/v1/webhooks/automations/")) {
return true;
}
if (c.req.method === "POST" && path === "/v1/webhooks/pr-review/github") {
return true;
}
if (
path === "/v1/github/setup" ||
path === "/v1/github/install/callback" ||
path === "/v1/github/oauth/callback" ||
path === "/v1/github/app-manifest/callback"
) {
return true;
}
if (
path === "/v1/pr-review/github/setup" ||
path === "/v1/pr-review/github/install/callback" ||
path === "/v1/pr-review/github/oauth/callback"
) {
return true;
}
if (
path === "/v1/integrations/oauth/callback" ||
path === "/v1/integrations/provider-oauth/callback" ||
path === "/v1/integrations/github-personal/oauth/callback" ||
path === "/v1/integrations/google-drive/callback" ||
path === "/v1/integrations/oauth/client-metadata.json" ||
path === "/v1/integrations/slack/callback" ||
path === "/v1/integrations/slack/events" ||
path === "/v1/integrations/slack/commands" ||
path === "/v1/integrations/slack/interactions" ||
// Fiken OAuth browser redirect: exact path only, protected by signed
// single-use state plus a callback-time grant recheck.
path === "/v1/integrations/fiken/callback"
) {
return true;
}
// Social OAuth (X / Reddit) browser redirect: exact path only, protected by
// signed single-use state plus a callback-time grant recheck.
if (path === "/v1/social/oauth/callback") {
return true;
}
// Catalog logos are rendered via bare
tags, which carry no credentials;
// the images are public vendor logos, digest-keyed by content, and the route
// itself enforces the catalog-assets/ prefix lock and extension whitelist.
if (path.startsWith("/v1/catalog-assets/")) {
return true;
}
// The GitHub owner-consent entry remains public like the callbacks above; it
// verifies fresh signed workspace-bound state before redirecting to GitHub.
if (githubConnectPathPattern.test(path)) {
return true;
}
if (prReviewGithubBrowserPathPattern.test(path)) {
return true;
}
// Compatibility endpoint for stale chooser submissions. It remains public
// only so already-rendered forms can authenticate their signed account and
// workspace state locally before terminating with 410; it does not parse a
// ticket, resolve browser authority, or write an installation binding.
if (c.req.method === "POST" && githubInstallationLinkPathPattern.test(path)) {
return true;
}
// The get. install-serving routes (install.sh/.ps1/uninstall.sh/
// minisign pub + the release-binary redirects). Reached by a fresh machine
// with no credentials; the bodies carry no secrets.
if (installExactPaths.has(path) || isInstallRedirectPath(path)) {
return true;
}
if (
settings.authAllowHealth &&
(path === "/healthz" || path === "/readyz" || path === "/traffic-readyz")
) {
return true;
}
if (settings.authAllowMetrics && path === "/metrics") {
return true;
}
return false;
}
async function isAuthorized(c: Context, settings: Settings): Promise {
const expected = settings.accessKey;
const explicit = c.req.header("x-opengeni-access-key");
if (expected && constantTimeEqual(explicit, expected)) {
return true;
}
const authorization = c.req.header("authorization");
const bearer = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: undefined;
if (expected && constantTimeEqual(bearer, expected)) {
return true;
}
// This middleware is only the deployment perimeter. A valid first-party
// delegated bearer is already authenticated with the deployment's separate
// delegation authority, so admit it only to the product API's route-level
// access resolver. Deployment-only surfaces such as /metrics still require
// the static deployment key. The resolver enforces exact account, workspace,
// principal, and permissions; attempt-bound surfaces add live-attempt fences.
const delegationSecret = resolveFirstPartyDelegationSecret(settings);
return Boolean(
c.req.path.startsWith("/v1/") &&
bearer &&
delegationSecret &&
(await verifyDelegatedAccessToken(delegationSecret, bearer)),
);
}
function constantTimeEqual(actual: string | undefined, expected: string): boolean {
if (typeof actual !== "string") {
return false;
}
const actualBytes = new TextEncoder().encode(actual);
const expectedBytes = new TextEncoder().encode(expected);
if (actualBytes.length !== expectedBytes.length) {
return false;
}
let diff = 0;
for (let index = 0; index < actualBytes.length; index += 1) {
diff |= actualBytes[index]! ^ expectedBytes[index]!;
}
return diff === 0;
}