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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions apps/ops-console/lib/ops-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function createLoginResponse(request: NextRequest): NextResponse {
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
authorizationUrl.searchParams.set("code_challenge_method", "S256");

const response = NextResponse.redirect(authorizationUrl);
const response = noStoreRedirect(authorizationUrl);
setEncryptedCookie(response, oidcStateCookieName(), {
state,
nonce,
Expand Down Expand Up @@ -144,7 +144,7 @@ export async function createCallbackResponse(request: NextRequest): Promise<Next
operatorKey: subject ? operatorLeaseOwner(subject, issuer ?? "") : undefined,
email: typeof claims.email === "string" ? claims.email : undefined
};
const response = NextResponse.redirect(new URL(expected.returnTo, publicOrigin(request)));
const response = noStoreRedirect(new URL(expected.returnTo, publicOrigin(request)));
setEncryptedCookie(response, sessionCookieName(), session, COOKIE_MAX_AGE_SECONDS);
clearCookie(response, oidcStateCookieName());
return response;
Expand All @@ -158,7 +158,7 @@ export function createLogoutResponse(request: NextRequest): NextResponse {
logoutUrl.searchParams.set("client_id", config.clientId);
logoutUrl.searchParams.set("post_logout_redirect_uri", origin);
}
const response = NextResponse.redirect(logoutUrl);
const response = noStoreRedirect(logoutUrl);
clearCookie(response, sessionCookieName());
clearCookie(response, oidcStateCookieName());
return response;
Expand Down Expand Up @@ -358,5 +358,14 @@ function requiredEnv(name: string): string {
function authErrorRedirect(request: NextRequest, detail: string): NextResponse {
const url = new URL("/", publicOrigin(request));
url.searchParams.set("auth_error", detail);
return NextResponse.redirect(url);
const response = noStoreRedirect(url);
clearCookie(response, sessionCookieName());
clearCookie(response, oidcStateCookieName());
return response;
}

function noStoreRedirect(url: URL): NextResponse {
const response = NextResponse.redirect(url);
response.headers.set("cache-control", "no-store");
return response;
}
55 changes: 54 additions & 1 deletion apps/ops-console/tests/ops-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";

import { encryptedSessionCookieForTest, validateOpsRuntimeEnv } from "../lib/ops-session";
import { NextRequest } from "next/server";

import {
createCallbackResponse,
encryptedSessionCookieForTest,
validateOpsRuntimeEnv
} from "../lib/ops-session";

const LOCAL_SECRET = "local-ops-console-session-secret-change-before-production";

function setRequiredOidcEnv() {
process.env.OMNICLAW_OPS_SESSION_SECRET = "test-session-secret-with-enough-entropy-123";
process.env.OMNICLAW_OPS_OIDC_CLIENT_ID = "omniclaw-ops-console";
process.env.OMNICLAW_OPS_OIDC_AUTHORIZATION_URL = "https://idp.example.test/auth";
process.env.OMNICLAW_OPS_OIDC_TOKEN_URL = "https://idp.example.test/token";
process.env.OMNICLAW_OPS_OIDC_REDIRECT_URI = "https://ops.example.test/api/auth/callback";
process.env.OMNICLAW_OPS_PUBLIC_ORIGIN = "https://ops.example.test";
}

test("production session secret rejects local default outside local compose", () => {
const previousNodeEnv = process.env.NODE_ENV;
const previousSecret = process.env.OMNICLAW_OPS_SESSION_SECRET;
Expand Down Expand Up @@ -140,3 +155,41 @@ test("local compose may use the checked-in development session secret", () => {
}
}
});

test("OIDC callback auth errors clear stale auth cookies and disable caching", async () => {
const previousEnv = {
NODE_ENV: process.env.NODE_ENV,
OMNICLAW_OPS_SESSION_SECRET: process.env.OMNICLAW_OPS_SESSION_SECRET,
OMNICLAW_OPS_OIDC_CLIENT_ID: process.env.OMNICLAW_OPS_OIDC_CLIENT_ID,
OMNICLAW_OPS_OIDC_AUTHORIZATION_URL: process.env.OMNICLAW_OPS_OIDC_AUTHORIZATION_URL,
OMNICLAW_OPS_OIDC_TOKEN_URL: process.env.OMNICLAW_OPS_OIDC_TOKEN_URL,
OMNICLAW_OPS_OIDC_REDIRECT_URI: process.env.OMNICLAW_OPS_OIDC_REDIRECT_URI,
OMNICLAW_OPS_PUBLIC_ORIGIN: process.env.OMNICLAW_OPS_PUBLIC_ORIGIN,
OMNICLAW_OPS_INSECURE_BACKEND_CONTEXT: process.env.OMNICLAW_OPS_INSECURE_BACKEND_CONTEXT
};
const env = process.env as Record<string, string | undefined>;
try {
env.NODE_ENV = "production";
delete process.env.OMNICLAW_OPS_INSECURE_BACKEND_CONTEXT;
setRequiredOidcEnv();

const response = await createCallbackResponse(
new NextRequest("https://ops.example.test/api/auth/callback?state=stale&code=missing-cookie")
);
const setCookieHeader = response.headers.get("set-cookie") ?? "";

assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal(response.headers.get("location"), "https://ops.example.test/?auth_error=OIDC+state+is+missing");
assert.match(setCookieHeader, /__Host-omniclaw_ops_session=/);
assert.match(setCookieHeader, /__Host-omniclaw_ops_oidc=/);
assert.match(setCookieHeader, /Max-Age=0/);
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete env[key];
} else {
env[key] = value;
}
}
}
});
49 changes: 37 additions & 12 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ services:
environment:
POSTGRES_DB: omniclaw
POSTGRES_USER: omniclaw
POSTGRES_PASSWORD: omniclaw
POSTGRES_PASSWORD: ${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U omniclaw -d omniclaw"]
interval: 5s
Expand All @@ -15,6 +15,21 @@ services:
volumes:
- hosted-postgres-data:/var/lib/postgresql/data

postgres-auth-init:
image: postgres:16-alpine
environment:
PGPASSWORD: ${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}
depends_on:
postgres:
condition: service_healthy
restart: "no"
command:
[
"sh",
"-ec",
"psql -h postgres -U omniclaw -d omniclaw -v ON_ERROR_STOP=1 -c 'CREATE SCHEMA IF NOT EXISTS keycloak AUTHORIZATION omniclaw;'",
]

redis:
image: redis:7-alpine
command: redis-server --appendonly yes --appendfsync everysec
Expand Down Expand Up @@ -51,18 +66,28 @@ services:

keycloak:
image: quay.io/keycloak/keycloak:26.1.4
command: ["start-dev", "--import-realm"]
command: ["start", "--import-realm"]
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_BOOTSTRAP_ADMIN_USERNAME: ${OMNICLAW_KEYCLOAK_ADMIN_USERNAME:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${OMNICLAW_KEYCLOAK_ADMIN_PASSWORD:-admin}
KC_DB: postgres
KC_DB_URL_HOST: postgres
KC_DB_URL_DATABASE: omniclaw
KC_DB_SCHEMA: keycloak
KC_DB_USERNAME: omniclaw
KC_DB_PASSWORD: ${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}
KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true"
KC_HOSTNAME: http://127.0.0.1:8081
KC_HOSTNAME_STRICT: "false"
KC_HOSTNAME_STRICT: "true"
KC_PROXY_HEADERS: xforwarded
ports:
- "127.0.0.1:8081:8080"
volumes:
- ./infra/auth-bootstrap/keycloak:/opt/keycloak/data/import:ro
depends_on:
postgres-auth-init:
condition: service_completed_successfully
healthcheck:
test:
[
Expand Down Expand Up @@ -117,9 +142,9 @@ services:
OMNICLAW_X402_FACILITATOR_PORT: 4022
OMNICLAW_HOSTED_INITIALIZE_STORAGE: "true"
OMNICLAW_HOSTED_INTERNAL_READY_ALLOW_PRIVATE_NETWORKS: "true"
OMNICLAW_HOSTED_FACILITATOR_POSTGRES_DSN: postgresql://omniclaw:omniclaw@postgres:5432/omniclaw
OMNICLAW_HOSTED_CONTROL_PLANE_POSTGRES_DSN: postgresql://omniclaw:omniclaw@postgres:5432/omniclaw
OMNICLAW_HOSTED_SELLER_ACCOUNTS_POSTGRES_DSN: postgresql://omniclaw:omniclaw@postgres:5432/omniclaw
OMNICLAW_HOSTED_FACILITATOR_POSTGRES_DSN: postgresql://omniclaw:${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}@postgres:5432/omniclaw
OMNICLAW_HOSTED_CONTROL_PLANE_POSTGRES_DSN: postgresql://omniclaw:${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}@postgres:5432/omniclaw
OMNICLAW_HOSTED_SELLER_ACCOUNTS_POSTGRES_DSN: postgresql://omniclaw:${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}@postgres:5432/omniclaw
OMNICLAW_HOSTED_RATE_LIMIT_REDIS_URL: redis://redis:6379/0
OMNICLAW_HOSTED_OIDC_ISSUER: http://127.0.0.1:8081/realms/omniclaw
OMNICLAW_HOSTED_OIDC_AUDIENCE: omniclaw-ops
Expand Down Expand Up @@ -206,9 +231,9 @@ services:
environment:
OMNICLAW_HOSTED_INITIALIZE_STORAGE: "false"
OMNICLAW_HOSTED_INTERNAL_READY_ALLOW_PRIVATE_NETWORKS: "true"
OMNICLAW_HOSTED_FACILITATOR_POSTGRES_DSN: postgresql://omniclaw:omniclaw@postgres:5432/omniclaw
OMNICLAW_HOSTED_CONTROL_PLANE_POSTGRES_DSN: postgresql://omniclaw:omniclaw@postgres:5432/omniclaw
OMNICLAW_HOSTED_SELLER_ACCOUNTS_POSTGRES_DSN: postgresql://omniclaw:omniclaw@postgres:5432/omniclaw
OMNICLAW_HOSTED_FACILITATOR_POSTGRES_DSN: postgresql://omniclaw:${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}@postgres:5432/omniclaw
OMNICLAW_HOSTED_CONTROL_PLANE_POSTGRES_DSN: postgresql://omniclaw:${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}@postgres:5432/omniclaw
OMNICLAW_HOSTED_SELLER_ACCOUNTS_POSTGRES_DSN: postgresql://omniclaw:${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}@postgres:5432/omniclaw
OMNICLAW_HOSTED_RATE_LIMIT_REDIS_URL: redis://redis:6379/0
OMNICLAW_HOSTED_EXACT_PROVIDER_TIMEOUT_SECONDS: "30"
OMNICLAW_HOSTED_POSTGRES_POOL_SIZE: "4"
Expand Down Expand Up @@ -250,7 +275,7 @@ services:
context: ./.
dockerfile: Dockerfile
environment:
OMNICLAW_HOSTED_FACILITATOR_POSTGRES_DSN: postgresql://omniclaw:omniclaw@postgres:5432/omniclaw
OMNICLAW_HOSTED_FACILITATOR_POSTGRES_DSN: postgresql://omniclaw:${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}@postgres:5432/omniclaw
OMNICLAW_HOSTED_FACILITATOR_HEALTH_URL: http://hosted-facilitator:4022/internal/readyz
OMNICLAW_HOSTED_RECONCILER_HEARTBEAT_PATH: /var/run/omniclaw/reconciler-heartbeat.json
OMNICLAW_HOSTED_METRICS_PORT: "9108"
Expand Down
2 changes: 2 additions & 0 deletions docs/deployment-docker-compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ infra/compose/.local-production.override.yml

The developer override may attach the production-layout services to the repository Compose dependency network so they can reach `postgres`, `redis`, `keycloak`, `openfga`, and the bundled OTEL collector. Those dependencies are test-only: Postgres is not TLS, Redis is not `rediss://`, OIDC/OpenFGA are HTTP, and OTEL is HTTP. Do not copy this override or these env files into hosted deployment.

The repository Compose Keycloak service still runs the Keycloak production server command, uses Postgres storage, pins the hostname, and accepts forwarded headers from the reverse proxy. The developer limitation is the dependency boundary: the bundled Postgres/Redis/OpenFGA/OTEL services are for local and alpha smoke testing, not managed production services.

Run production-layout smoke on alternate localhost ports so it does not replace the default Compose stack:

```bash
Expand Down
4 changes: 4 additions & 0 deletions hosted.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,9 @@ OMNICLAW_HOSTED_OTEL_COLLECTOR_HEALTH_URL=http://otel-collector:13133
OMNICLAW_HOSTED_JSON_LOGS=true

# Compose ops auth is configured by docker-compose.yml:
# - Keycloak runs in production server mode with Postgres storage.
# - For local development, optional .env values may override
# OMNICLAW_POSTGRES_PASSWORD, OMNICLAW_KEYCLOAK_ADMIN_USERNAME, and
# OMNICLAW_KEYCLOAK_ADMIN_PASSWORD.
# - Keycloak issues OIDC tokens for ops@omniclaw.ai / omniclaw-alpha-admin.
# - OpenFGA grants user:ops-alpha-admin security_admin on ops_console:default.
6 changes: 4 additions & 2 deletions infra/auth-bootstrap/keycloak/omniclaw-realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
"http://127.0.0.1:3001/api/auth/callback",
"http://localhost:3001/api/auth/callback",
"http://127.0.0.1:13001/api/auth/callback",
"http://localhost:13001/api/auth/callback"
"http://localhost:13001/api/auth/callback",
"https://ops.omniclaw.ai/api/auth/callback"
],
"webOrigins": [
"http://127.0.0.1:3001",
"http://localhost:3001",
"http://127.0.0.1:13001",
"http://localhost:13001"
"http://localhost:13001",
"https://ops.omniclaw.ai"
],
"attributes": {
"pkce.code.challenge.method": "S256"
Expand Down
46 changes: 46 additions & 0 deletions tests/test_hosted_deployment_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@


ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_COMPOSE_PATH = ROOT / "docker-compose.yml"
COMPOSE_PATH = ROOT / "infra" / "compose" / "docker-compose.production.yml"
ENV_EXAMPLE_PATH = ROOT / "infra" / "compose" / "production.env.example"
COMPOSE_ENV_EXAMPLE_PATH = ROOT / "infra" / "compose" / "production.compose.env.example"
Expand All @@ -21,6 +22,51 @@
OPS_DOCKERFILE_PATH = ROOT / "apps" / "ops-console" / "Dockerfile"


def test_repository_compose_keycloak_uses_production_mode_and_postgres():
data = yaml.safe_load(REPOSITORY_COMPOSE_PATH.read_text(encoding="utf-8"))
services = data["services"]
keycloak = services["keycloak"]

assert keycloak["command"] == ["start", "--import-realm"]
assert "start-dev" not in " ".join(keycloak["command"])
assert keycloak["depends_on"]["postgres-auth-init"] == {
"condition": "service_completed_successfully"
}
assert services["postgres-auth-init"]["command"][2].startswith("psql -h postgres -U omniclaw")

environment = keycloak["environment"]
assert environment["KC_BOOTSTRAP_ADMIN_USERNAME"] == (
"${OMNICLAW_KEYCLOAK_ADMIN_USERNAME:-admin}"
)
assert environment["KC_BOOTSTRAP_ADMIN_PASSWORD"] == (
"${OMNICLAW_KEYCLOAK_ADMIN_PASSWORD:-admin}"
)
assert environment["KC_DB"] == "postgres"
assert environment["KC_DB_URL_HOST"] == "postgres"
assert environment["KC_DB_URL_DATABASE"] == "omniclaw"
assert environment["KC_DB_SCHEMA"] == "keycloak"
assert environment["KC_PROXY_HEADERS"] == "xforwarded"
assert environment["KC_HOSTNAME_STRICT"] == "true"
assert "KEYCLOAK_ADMIN" not in environment
assert "KEYCLOAK_ADMIN_PASSWORD" not in environment

for service_name in ("hosted-facilitator", "hosted-reconciler", "hosted-metrics-exporter"):
service_environment = services[service_name]["environment"]
dsn = service_environment["OMNICLAW_HOSTED_FACILITATOR_POSTGRES_DSN"]
assert "${OMNICLAW_POSTGRES_PASSWORD:-omniclaw}" in dsn
assert "omniclaw:omniclaw@postgres" not in dsn


def test_keycloak_realm_allows_local_and_alpha_ops_redirects():
realm_path = ROOT / "infra" / "auth-bootstrap" / "keycloak" / "omniclaw-realm.json"
realm = yaml.safe_load(realm_path.read_text(encoding="utf-8"))
client = next(item for item in realm["clients"] if item["clientId"] == "omniclaw-ops-console")

assert "https://ops.omniclaw.ai/api/auth/callback" in client["redirectUris"]
assert "https://ops.omniclaw.ai" in client["webOrigins"]
assert realm["displayName"] == "OmniClaw Facilitator"


def test_production_compose_keeps_hosted_runtime_standalone():
data = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8"))
services = data["services"]
Expand Down
Loading