Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions docs/guides/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,11 @@ Key behaviours:

Multi-device and external operator access is now possible via **scoped sync tokens**:

- **`POST /api/sync/tokens`** — Issue a new sync token (scoped, with optional expiry)
- **`POST /api/sync/tokens`** — Issue a new sync token. Requires an authenticated dashboard session or the local CLI credential (`omniroute sync tokens create`); a `manage`-scoped API key alone is refused. Tokens expire after 30 days by default (`ttl` such as `12h`/`90d`, or `expiresInDays`; max 365 days)
- **`DELETE /api/sync/tokens/:id`** — Revoke a token
- **`GET /api/sync/bundle`** — Download a versioned, ETag-keyed JSON snapshot of all non-sensitive settings (passwords redacted)
- **`GET /api/sync/bundle`** — Download a versioned, ETag-keyed JSON snapshot of the instance configuration (settings with the admin password removed, provider connections **including their credentials**, combos, API keys, reasoning rules)

A sync token stops working when it expires, when it is revoked, when the admin password changes, or when the API key recorded as its issuer is revoked or deleted. While API key reveal is disabled (`ALLOW_API_KEY_REVEAL`), the bundle masks OmniRoute API keys the same way the API-keys listing does.

The config bundle is built by `src/lib/sync/bundle.ts`. Consumers compare the `ETag` response header to detect changes without re-downloading the full payload.

Expand Down
10 changes: 8 additions & 2 deletions src/app/api/sync/bundle/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildConfigSyncEnvelope } from "@/lib/sync/bundle";
import { buildConfigSyncEnvelope, redactApiKeySecretsForExport } from "@/lib/sync/bundle";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
import { getSyncTokenFromRequest, markSyncTokenUsed, validateSyncToken } from "@/lib/sync/tokens";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";

Expand Down Expand Up @@ -31,7 +32,12 @@ export async function GET(request: Request) {
});
}

const { version, bundle } = await buildConfigSyncEnvelope();
const envelope = await buildConfigSyncEnvelope();
const { version } = envelope;
// Same switch as /api/keys/{id}/reveal: no plaintext OmniRoute API keys while reveal is off.
const bundle = isApiKeyRevealEnabled()
? envelope.bundle
: redactApiKeySecretsForExport(envelope.bundle, maskStoredApiKey);
await markSyncTokenUsed(syncToken);

if (matchesEtag(request, version)) {
Expand Down
30 changes: 27 additions & 3 deletions src/app/api/sync/tokens/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
issueSyncToken,
listSyncTokenSummaries,
parseSyncTokenTtl,
resolveSyncApiKeyIdFromManagementRequest,
} from "@/lib/sync/tokens";
import { resolveSyncTokenIssuer } from "@/lib/sync/issuerAuth";

export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
Expand All @@ -26,8 +28,11 @@ export async function GET(request: Request) {
}

export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
// GHSA-2452-4qrm-6p4w: a sync token downloads every provider credential in plaintext, so
// minting one needs an operator credential (dashboard session / local CLI) — a
// manage-scoped API key alone is not enough.
const issuer = await resolveSyncTokenIssuer(request);
if (!issuer.ok) return issuer.response;

const auditContext = getAuditRequestContext(request);

Expand All @@ -47,16 +52,35 @@ export async function POST(request: Request) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}

let ttlMs: number | null = null;
if (validation.data.ttl) {
ttlMs = parseSyncTokenTtl(validation.data.ttl);
if (ttlMs === null) {
return createErrorResponse({
status: 400,
message: "ttl must be between 1 minute and 365 days",
});
}
} else if (validation.data.expiresInDays) {
ttlMs = validation.data.expiresInDays * 24 * 60 * 60 * 1000;
}

// When an API key accompanies the operator credential it is recorded as the token's
// issuer, and revoking/deleting that key invalidates the token.
const syncApiKeyId = await resolveSyncApiKeyIdFromManagementRequest(request);
const issued = await issueSyncToken({
name: validation.data.name,
issuerKind: issuer.issuerKind,
syncApiKeyId,
ttlMs,
});

const tokenSummary = {
id: issued.record.id,
name: issued.record.name,
syncApiKeyId: issued.record.syncApiKeyId,
issuerKind: issued.record.issuerKind,
expiresAt: issued.record.expiresAt,
revokedAt: issued.record.revokedAt,
lastUsedAt: issued.record.lastUsedAt,
createdAt: issued.record.createdAt,
Expand All @@ -65,7 +89,7 @@ export async function POST(request: Request) {

logAuditEvent({
action: "sync.token.created",
actor: "admin",
actor: issuer.issuerKind === "local_cli" ? "cli" : "admin",
target: issued.record.name,
resourceType: "sync_token",
status: "success",
Expand Down
16 changes: 16 additions & 0 deletions src/lib/db/migrations/206_sync_token_issuer_binding.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- 206: GHSA-2452-4qrm-6p4w — bind sync tokens to the credential that minted them.
-- expires_at : ISO timestamp; tokens without one are no longer accepted.
-- issuer_kind : who minted it (dashboard_session | local_cli | open_instance).
-- password_fingerprint : digest of the admin password hash at mint time; a password
-- rotation changes it and invalidates the token.
-- Rows minted before this migration have no binding (some may have been minted by a
-- manage-scoped API key) and are revoked here; validation also rejects them.

ALTER TABLE sync_tokens ADD COLUMN expires_at TEXT;
ALTER TABLE sync_tokens ADD COLUMN issuer_kind TEXT;
ALTER TABLE sync_tokens ADD COLUMN password_fingerprint TEXT;

UPDATE sync_tokens
SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE revoked_at IS NULL AND issuer_kind IS NULL;
27 changes: 25 additions & 2 deletions src/lib/db/syncTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export interface SyncTokenRecord {
syncApiKeyId: string | null;
revokedAt: string | null;
lastUsedAt: string | null;
/** ISO timestamp after which the token is rejected (GHSA-2452). Null only on legacy rows. */
expiresAt: string | null;
/** Who minted the token (dashboard_session | local_cli | open_instance). Null on legacy rows. */
issuerKind: string | null;
/** Digest of the admin password hash at mint time; a password change invalidates the token. */
passwordFingerprint: string | null;
createdAt: string;
updatedAt: string;
}
Expand Down Expand Up @@ -43,6 +49,10 @@ function toSyncTokenRecord(value: unknown): SyncTokenRecord | null {
syncApiKeyId: typeof record.syncApiKeyId === "string" ? record.syncApiKeyId : null,
revokedAt: typeof record.revokedAt === "string" ? record.revokedAt : null,
lastUsedAt: typeof record.lastUsedAt === "string" ? record.lastUsedAt : null,
expiresAt: typeof record.expiresAt === "string" ? record.expiresAt : null,
issuerKind: typeof record.issuerKind === "string" ? record.issuerKind : null,
passwordFingerprint:
typeof record.passwordFingerprint === "string" ? record.passwordFingerprint : null,
createdAt: typeof record.createdAt === "string" ? record.createdAt : new Date().toISOString(),
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(),
};
Expand All @@ -57,6 +67,9 @@ function ensureSyncTokensTable(db: DbLike) {
sync_api_key_id TEXT,
revoked_at TEXT,
last_used_at TEXT,
expires_at TEXT,
issuer_kind TEXT,
password_fingerprint TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Expand Down Expand Up @@ -100,6 +113,9 @@ export async function createSyncTokenRecord(data: {
name: string;
tokenHash: string;
syncApiKeyId?: string | null;
expiresAt?: string | null;
issuerKind?: string | null;
passwordFingerprint?: string | null;
}) {
const db = getDbInstance() as unknown as DbLike;
ensureSyncTokensTable(db);
Expand All @@ -112,21 +128,28 @@ export async function createSyncTokenRecord(data: {
syncApiKeyId: data.syncApiKeyId || null,
revokedAt: null,
lastUsedAt: null,
expiresAt: data.expiresAt ?? null,
issuerKind: data.issuerKind ?? null,
passwordFingerprint: data.passwordFingerprint ?? null,
createdAt: now,
updatedAt: now,
};

db.prepare(
`INSERT INTO sync_tokens (
id, name, token_hash, sync_api_key_id, revoked_at, last_used_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
id, name, token_hash, sync_api_key_id, revoked_at, last_used_at,
expires_at, issuer_kind, password_fingerprint, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
record.id,
record.name,
record.tokenHash,
record.syncApiKeyId,
record.revokedAt,
record.lastUsedAt,
record.expiresAt,
record.issuerKind,
record.passwordFingerprint,
record.createdAt,
record.updatedAt
);
Expand Down
19 changes: 19 additions & 0 deletions src/lib/sync/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,25 @@ export async function buildConfigSyncEnvelope() {
};
}

/**
* GHSA-2452: GET /api/sync/bundle must not hand out OmniRoute API keys in plaintext while
* key reveal is disabled — GET /api/keys masks them and /api/keys/{id}/reveal returns 403,
* so the sync export follows the same switch. The key's configuration is still synced; only
* the secret is masked the same way the API-keys listing masks it. Cloud sync builds its
* payload from `buildConfigSyncEnvelope()` directly and is unaffected.
*/
export function redactApiKeySecretsForExport(
bundle: ConfigSyncBundle,
mask: (key: unknown) => string | null
): ConfigSyncBundle {
return {
...bundle,
apiKeys: bundle.apiKeys.map((apiKey) =>
typeof apiKey.key === "string" ? { ...apiKey, key: mask(apiKey.key) } : { ...apiKey }
),
};
}

export function toLegacyCloudSyncPayload(bundle: ConfigSyncBundle) {
return {
providers: bundle.providerConnections,
Expand Down
77 changes: 77 additions & 0 deletions src/lib/sync/issuerAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { createErrorResponse } from "@/lib/api/errorResponse";
import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth";
import { AUTHZ_HEADER_AUTH_KIND, AUTHZ_HEADER_AUTH_LABEL } from "@/server/authz/headers";
import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
import type { SyncTokenIssuerKind } from "@/lib/sync/tokens";

export type SyncTokenIssuerResolution =
{ ok: true; issuerKind: SyncTokenIssuerKind } | { ok: false; response: Response };

/**
* Who may mint a sync token (GHSA-2452-4qrm-6p4w).
*
* A sync token downloads the full config bundle — every provider credential in plaintext —
* so minting one is an operator action, not something an API key can do on its own:
* - an authenticated dashboard session (the admin logged in with the password / OIDC),
* - the machine-bound local CLI credential (loopback only, stamped by the authz pipeline
* or validated here for raw callers) — keeps `omniroute sync tokens create` working,
* - an instance with login disabled (every management surface is already open).
*
* Manage/admin-scoped API keys and scoped `oma_` access tokens are refused: a manage key can
* be handed to automation and must not be able to extract credentials that GET
* /api/providers masks and /api/keys/{id}/reveal withholds.
*/
export async function resolveSyncTokenIssuer(request: Request): Promise<SyncTokenIssuerResolution> {
try {
if (!(await isAuthRequired(request, { ignorePublicRoute: true }))) {
return { ok: true, issuerKind: "open_instance" };
}

if (await isDashboardSessionAuthenticated(request)) {
return { ok: true, issuerKind: "dashboard_session" };
}

if (
request.headers.get(AUTHZ_HEADER_AUTH_KIND) === "management_key" &&
request.headers.get(AUTHZ_HEADER_AUTH_LABEL) === "local-cli-token"
) {
return { ok: true, issuerKind: "local_cli" };
}

if (await isCliTokenAuthValid(request)) {
return { ok: true, issuerKind: "local_cli" };
}
} catch {
return {
ok: false,
response: createErrorResponse({
status: 503,
message: "Service temporarily unavailable",
type: "server_error",
}),
};
}

const hasCredential =
Boolean(request.headers.get("authorization")) || Boolean(request.headers.get("x-api-key"));
if (hasCredential) {
return {
ok: false,
response: createErrorResponse({
status: 403,
message:
"Sync tokens can only be issued from an authenticated dashboard session or the local CLI.",
type: "invalid_request",
}),
};
}

return {
ok: false,
response: createErrorResponse({
status: 401,
message: "Authentication required",
type: "invalid_request",
}),
};
}
Loading
Loading