Skip to content
Open
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
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
COPY packages/build/package.json packages/build/
COPY packages/ui-extensions-sdk/package.json packages/ui-extensions-sdk/
RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false \
--filter @tangent/server...

Expand All @@ -61,6 +62,7 @@ RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false \
COPY apps/server ./apps/server
COPY packages/shared ./packages/shared
COPY packages/build ./packages/build
COPY packages/ui-extensions-sdk ./packages/ui-extensions-sdk

# Produces apps/server/dist/index.js plus its runtime assets (prompts, agents,
# extensions, migrations). Invoked via node directly to avoid pnpm's pre-run
Expand Down
12 changes: 12 additions & 0 deletions Dockerfile.fullstack
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
COPY packages/build/package.json packages/build/
COPY packages/ui-primitives/package.json packages/ui-primitives/
COPY packages/ui-extensions-sdk/package.json packages/ui-extensions-sdk/
COPY packages/windows/package.json packages/windows/
COPY packages/analytics/package.json packages/analytics/
COPY packages/utils/package.json packages/utils/
COPY packages/embed-react/package.json packages/embed-react/
COPY packages/remote-subagent/package.json packages/remote-subagent/
RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false

# Server + UI source plus the shared workspace packages they build against
Expand All @@ -82,6 +85,15 @@ RUN node apps/server/build.mjs
RUN VITE_DEFAULT_SESSION_BUNDLE_ID=__TANGENT_RUNTIME_DEFAULT_SESSION_BUNDLE_ID__ \
pnpm --filter @tangent/web exec vite build

# Build the embedded UI runtime -> apps/web/dist/embed/v1 (served by nginx at
# /embed/). Runs after the main build because its outDir lives under dist/; the
# main build's emptyOutDir would otherwise wipe it.
RUN pnpm --filter @tangent/web exec vite build --config vite.embed.config.ts

# Fail early if the embed bundle is missing.
RUN test -f apps/web/dist/embed/v1/tangent-elements.js \
|| { echo "missing apps/web/dist/embed/v1/tangent-elements.js" >&2; exit 1; }

# Defense in depth: fail the image build if any extension the server loads at
# runtime is missing from the bundle (the build script also asserts this).
RUN for f in orchestrator proxyProvider memory triggers; do \
Expand Down
50 changes: 34 additions & 16 deletions apps/server/src/auth/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,8 @@ function pickString(
return "";
}

/**
* Resolves the current {@link UserIdentity} from a raw `Cookie` header. Reads
* the Oktasso JWT from {@link AUTH_JWT_TOKEN_COOKIE_NAME}, decodes its payload
* (no signature check), and maps the email + name claims onto the identity.
*
* Returns `null` when the cookie name is unconfigured, the cookie is missing,
* the token is malformed, or it carries no email. Name claims fall back across
* the OIDC standard (`given_name` / `family_name`) and snake-case
* (`first_name` / `last_name`) variants, defaulting to `""` when absent.
*/
export function resolveUserIdentity(
cookieHeader: string | undefined,
): UserIdentity | null {
if (!AUTH_JWT_TOKEN_COOKIE_NAME) return null;

const token = parseCookies(cookieHeader)[AUTH_JWT_TOKEN_COOKIE_NAME];
/** Maps a decoded JWT (no signature check) onto a {@link UserIdentity}. */
function identityFromToken(token: string | undefined): UserIdentity | null {
if (!token) return null;

const payload = decodeJwtPayload(token);
Expand All @@ -81,3 +67,35 @@ export function resolveUserIdentity(
last_name: pickString(payload, ["last_name", "family_name"]),
};
}

/** Extracts the token from an `Authorization: Bearer <jwt>` header. */
function bearerToken(header: string | undefined): string | undefined {
const match = /^Bearer\s+(.+)$/i.exec((header ?? "").trim());
return match?.[1];
}

/**
* Resolves the current {@link UserIdentity} from an incoming request's
* credentials. Prefers an `Authorization: Bearer` JWT (the embed passes one
* cross-origin, where cookies are unavailable) and falls back to the Oktasso
* JWT in {@link AUTH_JWT_TOKEN_COOKIE_NAME}. The payload is decoded without a
* signature check and mapped from the email + name claims.
*
* Returns `null` when no token resolves, the token is malformed, or it carries
* no email. The cookie path additionally requires the cookie name to be
* configured; the bearer path does not. Name claims fall back across the OIDC
* standard (`given_name` / `family_name`) and snake-case (`first_name` /
* `last_name`) variants, defaulting to `""` when absent.
*/
export function resolveUserIdentity(
cookieHeader: string | undefined,
authorizationHeader?: string | undefined,
): UserIdentity | null {
const bearer = bearerToken(authorizationHeader);
if (bearer) return identityFromToken(bearer);

if (!AUTH_JWT_TOKEN_COOKIE_NAME) return null;
return identityFromToken(
parseCookies(cookieHeader)[AUTH_JWT_TOKEN_COOKIE_NAME],
);
}
20 changes: 20 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ export const PUBLIC_URL = (process.env.TANGENT_PUBLIC_URL ?? "").replace(
*/
export const REMOTE_ENV_TOKEN = process.env.REMOTE_ENV_TOKEN ?? "";

/**
* HMAC key used to mint and verify scoped `/remote-env` tokens for embed hosts.
* Generated per server start unless pinned via env. Independent of
* {@link REMOTE_ENV_TOKEN} (the optional server-to-server shared secret) and of
* {@link INTERNAL_TOKEN} (which Pi children inherit).
*/
export const REMOTE_ENV_SIGNING_SECRET =
process.env.REMOTE_ENV_SIGNING_SECRET ?? randomUUID();

/**
* Secret Tangent presents (as a bearer token) to an attached A2A agent. Unlike
* the other connector secrets this one travels outbound, so an empty value is
Expand All @@ -185,6 +194,17 @@ export const A2A_TOKEN = process.env.A2A_TOKEN ?? "";
export const AUTH_JWT_TOKEN_COOKIE_NAME =
process.env.AUTH_JWT_TOKEN_COOKIE_NAME ?? "";

/**
* Origins allowed to embed the UI cross-origin (the host pages running
* `@tangent/embed-react`). Comma-separated; drives both the `/api` CORS headers
* and the Socket.IO handshake allowlist. Empty by default so a same-origin
* deployment grants no cross-origin trust.
*/
export const EMBED_ALLOWED_ORIGINS = (process.env.EMBED_ALLOWED_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);

/**
* Base URL of the Tangle API reached by the bundle-UI/agent egress allowlist.
* The OpenAPI doc declares no `servers`, so this is supplied per environment.
Expand Down
82 changes: 82 additions & 0 deletions apps/server/src/connectors/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { test } from "node:test";

import {
Expand All @@ -9,6 +10,7 @@ import {
InheritedTokenCredential,
mintSecretCredential,
PeerBearerCredential,
ScopedTokenCredential,
} from "./credentials.ts";

test("a bearer credential accepts only its own token, exactly", () => {
Expand All @@ -26,12 +28,16 @@ test("an unset secret authorizes nobody, rather than everybody", () => {
// refuse every caller, including one presenting the empty string.
const bearer = new BearerCredential("internal-bearer", "");
const handshake = new HandshakeTokenCredential("");
const scoped = new ScopedTokenCredential("");

assert.equal(bearer.configured, false);
assert.equal(bearer.verify({ authorization: "Bearer " }), false);
assert.equal(handshake.configured, false);
assert.equal(handshake.verify({ token: "" }), false);
assert.equal(handshake.verify({ token: undefined }), false);
assert.equal(scoped.configured, false);
assert.equal(scoped.verify({ token: "re1.payload.mac" }), false);
assert.equal(scoped.parse("re1.payload.mac"), null);
});

test("the handshake credential reads the handshake, not a header", () => {
Expand All @@ -51,6 +57,7 @@ test("only an inherited credential hands its secret to a spawned child", () => {
{},
);
assert.deepEqual(new HandshakeTokenCredential("tok").spawnEnv(), {});
assert.deepEqual(new ScopedTokenCredential("tok").spawnEnv(), {});
assert.deepEqual(new PeerBearerCredential("tok").spawnEnv(), {});
assert.deepEqual(deniedCredential.spawnEnv(), {});
});
Expand Down Expand Up @@ -112,3 +119,78 @@ test("the denied credential authorizes nothing at all", () => {
assert.equal(deniedCredential.verify({ authorization: "Bearer x" }), false);
assert.equal(deniedCredential.verify({ token: "x" }), false);
});

const SCOPED_INPUT = {
environmentId: "env-1",
sessionId: "s1",
sub: "user@example.com",
};

test("a scoped token round-trips through mint and verify", () => {
const credential = new ScopedTokenCredential("signing-secret");
const minted = credential.mint(SCOPED_INPUT);

assert.equal(credential.scheme, "scoped-token");
assert.equal(credential.verify({ token: minted.token }), true);
assert.equal(
credential.verify({ authorization: `Bearer ${minted.token}` }),
false,
);

const claims = credential.parse(minted.token);
assert.ok(claims);
assert.equal(claims.scope, "remote-env");
assert.equal(claims.environmentId, SCOPED_INPUT.environmentId);
assert.equal(claims.sessionId, SCOPED_INPUT.sessionId);
assert.equal(claims.sub, SCOPED_INPUT.sub);
assert.equal(minted.expiresAt, new Date(claims.exp * 1000).toISOString());
});

test("a scoped token with a tampered payload is refused", () => {
const credential = new ScopedTokenCredential("signing-secret");
const { token } = credential.mint(SCOPED_INPUT);
const [prefix, payload, mac] = token.split(".");
const claims = JSON.parse(
Buffer.from(payload, "base64url").toString("utf8"),
) as Record<string, unknown>;
claims.sessionId = "other-session";
const tampered = Buffer.from(JSON.stringify(claims)).toString("base64url");

assert.equal(
credential.verify({ token: `${prefix}.${tampered}.${mac}` }),
false,
);
});

test("an expired scoped token is refused", () => {
const credential = new ScopedTokenCredential("signing-secret", 0);
const { token } = credential.mint(SCOPED_INPUT);

assert.equal(credential.verify({ token }), false);
assert.equal(credential.parse(token), null);
});

test("a scoped token with the wrong scope is refused", () => {
const secret = "signing-secret";
const credential = new ScopedTokenCredential(secret);
const payload = Buffer.from(
JSON.stringify({
scope: "other",
environmentId: "env-1",
sessionId: "s1",
sub: "user@example.com",
iat: 1,
exp: 4_000_000_000,
}),
).toString("base64url");
const mac = createHmac("sha256", secret).update(payload).digest("base64url");

assert.equal(credential.verify({ token: `re1.${payload}.${mac}` }), false);
});

test("minting is refused when the scoped signing secret is unset", () => {
assert.throws(
() => new ScopedTokenCredential("").mint(SCOPED_INPUT),
/not configured/,
);
});
Loading
Loading