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
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ DevSpace uses a single-user OAuth approval flow.
| `DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `2592000` |
| `DEVSPACE_OAUTH_SCOPES` | `devspace` |
| `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `chatgpt.com,localhost,127.0.0.1` |
| `DEVSPACE_OAUTH_CLIENT_REGISTRATION_KEY` | Generated by `devspace init`; older configs derive a compatibility key from the Owner password; at least 32 characters when supplied explicitly |

New public OAuth registrations use a signed client identifier. If the SQLite
client row is later lost, a reconnect can reconstruct the original registration,
revalidate its redirect URI against the current allowlist, and show the Owner
password approval page again. The client row is restored only after successful
approval.

Access and refresh tokens remain opaque, hashed, stateful, and revocable. They
are never reconstructed from the client identifier. Registrations created by an
older DevSpace version still use random client identifiers and must register
once with the newer version before this recovery path applies.

MCP clients discover metadata from:

Expand Down
13 changes: 13 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,23 @@ reach.
When an MCP client connects, DevSpace shows an approval page. Enter the Owner
password only when you intentionally want that client to access this server.

Fresh setups store a separate random key in the same private file to authenticate
recoverable OAuth client registrations. Older auth files without that field use
a memory-hard compatibility key derived from the Owner password. The resulting
client identifier is public and is not a credential: recovery still requires an
exact registered redirect URI, PKCE, the current redirect-host allowlist, and a
fresh Owner password approval. Access and refresh tokens are not recoverable and
remain revocable server-side state.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Repository coverage exercises this recovery through `SingleUserOAuthProvider`
in process. A packaged reconnect through a real MCP host has not yet been
verified.

For env-driven deployments, set a long random value:

```bash
DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)"
DEVSPACE_OAUTH_CLIENT_REGISTRATION_KEY="$(openssl rand -base64 32)"
```

## Public URL And Host Allowlist
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-client-registration.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
10 changes: 9 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-stor
import type { LocalAgentRunResult } from "./local-agent-runtime.js";
import {
ensureDevspaceDefaultSkills,
deriveClientRegistrationKey,
generateClientRegistrationKey,
generateOwnerToken,
loadDevspaceFiles,
resolveSubagentsFlag,
Expand Down Expand Up @@ -163,8 +165,14 @@ async function runInit({ force }: { force: boolean }): Promise<void> {
publicBaseUrl,
subagents: resolveSubagentsFlag(files.config),
};
const ownerToken = files.auth.ownerToken ?? generateOwnerToken();
const auth = {
ownerToken: files.auth.ownerToken ?? generateOwnerToken(),
ownerToken,
clientRegistrationKey:
files.auth.clientRegistrationKey ??
(files.auth.ownerToken
? deriveClientRegistrationKey(files.auth.ownerToken)
: generateClientRegistrationKey()),
};

const configPath = writeDevspaceConfig(config);
Expand Down
23 changes: 23 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ assert.throws(
);

assert.equal(loadConfig(baseEnv).oauth.ownerToken, "test-owner-token-that-is-long-enough");
const derivedClientRegistrationKey = loadConfig(baseEnv).oauth.clientRegistrationKey;
assert.equal(derivedClientRegistrationKey.length, 43);
assert.equal(loadConfig(baseEnv).oauth.clientRegistrationKey, derivedClientRegistrationKey);
assert.equal(
loadConfig({
...baseEnv,
DEVSPACE_OAUTH_CLIENT_REGISTRATION_KEY: "explicit-client-registration-key-long-enough",
}).oauth.clientRegistrationKey,
"explicit-client-registration-key-long-enough",
);
assert.throws(
() =>
loadConfig({
...baseEnv,
DEVSPACE_OAUTH_CLIENT_REGISTRATION_KEY: "too-short",
}),
/DEVSPACE_OAUTH_CLIENT_REGISTRATION_KEY must be at least 32 characters long/,
);
assert.deepEqual(loadConfig(baseEnv).oauth.scopes, ["devspace"]);
assert.deepEqual(loadConfig(baseEnv).oauth.allowedRedirectHosts, [
"chatgpt.com",
Expand Down Expand Up @@ -182,12 +200,17 @@ writeFileSync(
join(configDir, "auth.json"),
JSON.stringify({
ownerToken: "persisted-owner-token-long-enough",
clientRegistrationKey: "persisted-client-registration-key-long-enough",
}),
);

const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir });
assert.equal(fileConfig.port, 8787);
assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough");
assert.equal(
fileConfig.oauth.clientRegistrationKey,
"persisted-client-registration-key-long-enough",
);
assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com");
assert.equal(fileConfig.subagents, true);
assert.equal(fileConfig.artifactsEnabled, true);
Expand Down
38 changes: 31 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { join, resolve } from "node:path";
import { expandHomePath } from "./roots.js";
import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js";
import type { OAuthConfig } from "./oauth-provider.js";
import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js";
import {
deriveClientRegistrationKey,
devspaceAgentsDir,
devspaceSkillsDir,
loadDevspaceFiles,
} from "./user-config.js";

export type ToolMode = "minimal" | "full" | "codex";
export type WidgetMode = "off" | "changes" | "full";
Expand Down Expand Up @@ -162,20 +167,39 @@ function parseWidgetMode(value: string | undefined): WidgetMode {
throw new Error(`Invalid DEVSPACE_WIDGETS: ${value}`);
}

function parseRequiredSecret(value: string | undefined, name: string): string {
function parseRequiredSecret(
value: string | undefined,
name: string,
minimumLength = 16,
): string {
const secret = value?.trim();
if (!secret) {
throw new Error(`${name} is required for DevSpace OAuth. Run: devspace init`);
}
if (secret.length < 16) {
throw new Error(`${name} must be at least 16 characters long.`);
if (secret.length < minimumLength) {
throw new Error(`${name} must be at least ${minimumLength} characters long.`);
}
return secret;
}

function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined): OAuthConfig {
function parseOAuthConfig(
env: NodeJS.ProcessEnv,
ownerToken: string | undefined,
clientRegistrationKey: string | undefined,
): OAuthConfig {
const resolvedOwnerToken = parseRequiredSecret(
env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken,
"DEVSPACE_OAUTH_OWNER_TOKEN",
);
return {
ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"),
ownerToken: resolvedOwnerToken,
clientRegistrationKey: parseRequiredSecret(
env.DEVSPACE_OAUTH_CLIENT_REGISTRATION_KEY ??
clientRegistrationKey ??
deriveClientRegistrationKey(resolvedOwnerToken),
"DEVSPACE_OAUTH_CLIENT_REGISTRATION_KEY",
32,
),
accessTokenTtlSeconds: parsePositiveInteger(
env.DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS,
DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS,
Expand Down Expand Up @@ -226,7 +250,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
return {
host,
port,
oauth: parseOAuthConfig(env, files.auth.ownerToken),
oauth: parseOAuthConfig(env, files.auth.ownerToken, files.auth.clientRegistrationKey),
allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots),
allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts),
publicBaseUrl,
Expand Down
64 changes: 64 additions & 0 deletions src/oauth-client-registration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import {
createRecoverableClientId,
recoverClientRegistration,
} from "./oauth-client-registration.js";

const signingKey = "test-client-registration-key-that-is-long-enough";
const client = {
redirect_uris: ["https://chatgpt.com/connector/oauth/test"],
client_name: "ChatGPT",
client_id_issued_at: 1_786_032_000,
token_endpoint_auth_method: "none" as const,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
};

const created = createRecoverableClientId(client, signingKey);
assert.equal(created.kind, "recoverable");
if (created.kind !== "recoverable") throw new Error("Expected recoverable client ID");
const clientId = created.clientId;
assert.match(clientId, /^devspace-v1\./);

const recovered = recoverClientRegistration(clientId, signingKey);
assert.ok(recovered);
assert.equal(recovered.client_id, clientId);
assert.equal(recovered.client_name, "ChatGPT");
assert.deepEqual(recovered.redirect_uris, client.redirect_uris);
assert.deepEqual(recovered.grant_types, client.grant_types);

const parts = clientId.split(".");
assert.equal(parts.length, 3);
assert.equal(
recoverClientRegistration(`${parts[0]}.${parts[1]}x.${parts[2]}`, signingKey),
undefined,
);
assert.equal(
recoverClientRegistration(`${parts[0]}.${parts[1]}.${parts[2]}x`, signingKey),
undefined,
);
assert.equal(recoverClientRegistration(clientId, `${signingKey}-wrong`), undefined);
assert.equal(recoverClientRegistration(`devspace-v1.${"x".repeat(5000)}.signature`, signingKey), undefined);
assert.equal(
recoverClientRegistration("devspace-0b3f9c1e-2d4a-4f77-9c0e-1a2b3c4d5e6f", signingKey),
undefined,
);
assert.equal(recoverClientRegistration(`${clientId}.extra`, signingKey), undefined);

assert.deepEqual(
createRecoverableClientId(
{
...client,
token_endpoint_auth_method: "client_secret_post",
client_secret: "must-not-be-embedded",
},
signingKey,
),
{ kind: "unsupported" },
);

const oversized = createRecoverableClientId(
{ ...client, client_name: "x".repeat(5000) },
signingKey,
);
assert.equal(oversized.kind, "too_large");
103 changes: 103 additions & 0 deletions src/oauth-client-registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import {
OAuthClientInformationFullSchema,
type OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js";

const CLIENT_ID_PREFIX = "devspace-v1";
const MAX_CLIENT_ID_LENGTH = 4096;

type ClientRegistrationPayload = Omit<OAuthClientInformationFull, "client_id">;

export type RecoverableClientIdResult =
| {
kind: "recoverable";
clientId: string;
registration: ClientRegistrationPayload;
}
| { kind: "unsupported" }
| { kind: "too_large"; length: number; maxLength: number };

export function createRecoverableClientId(
client: ClientRegistrationPayload,
signingKey: string,
): RecoverableClientIdResult {
const parsed = OAuthClientInformationFullSchema.safeParse({
...client,
client_id: "pending",
});
if (!parsed.success || !isPublicClient(parsed.data)) {
return { kind: "unsupported" };
}

const { client_id: _clientId, ...validatedRegistration } = parsed.data;
const payload = Buffer.from(JSON.stringify(validatedRegistration)).toString("base64url");
const signedValue = `${CLIENT_ID_PREFIX}.${payload}`;
const signature = sign(signedValue, signingKey);
const clientId = `${signedValue}.${signature}`;
if (clientId.length > MAX_CLIENT_ID_LENGTH) {
return {
kind: "too_large",
length: clientId.length,
maxLength: MAX_CLIENT_ID_LENGTH,
};
}

return {
kind: "recoverable",
clientId,
registration: validatedRegistration,
};
}

export function recoverClientRegistration(
clientId: string,
signingKey: string,
): OAuthClientInformationFull | undefined {
if (clientId.length > MAX_CLIENT_ID_LENGTH) return undefined;

const [prefix, payload, signature, extra] = clientId.split(".");
if (prefix !== CLIENT_ID_PREFIX || !payload || !signature || extra !== undefined) {
return undefined;
}

const signedValue = `${prefix}.${payload}`;
if (!safeEquals(signature, sign(signedValue, signingKey))) return undefined;

let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as unknown;
} catch {
return undefined;
}

const parsed = OAuthClientInformationFullSchema.safeParse({
...(isRecord(decoded) ? decoded : {}),
client_id: clientId,
});
if (!parsed.success || !isPublicClient(parsed.data)) return undefined;
return parsed.data;
}

function isPublicClient(client: OAuthClientInformationFull): boolean {
return (
client.token_endpoint_auth_method === "none" &&
client.client_secret === undefined &&
client.client_secret_expires_at === undefined
);
}

function sign(value: string, signingKey: string): string {
return createHmac("sha256", signingKey).update(value).digest("base64url");
}

function safeEquals(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.byteLength !== rightBuffer.byteLength) return false;
return timingSafeEqual(leftBuffer, rightBuffer);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
11 changes: 10 additions & 1 deletion src/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js";

export interface OAuthConfig {
ownerToken: string;
clientRegistrationKey: string;
accessTokenTtlSeconds: number;
refreshTokenTtlSeconds: number;
scopes: string[];
Expand Down Expand Up @@ -124,7 +125,11 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
) {
this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl);
this.oauthStore = new SqliteOAuthStore(stateDir);
this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts);
this.clientsStore = new SqliteOAuthClientsStore(
this.oauthStore,
config.allowedRedirectHosts,
config.clientRegistrationKey,
);
}

async authorize(
Expand Down Expand Up @@ -167,6 +172,10 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
return;
}

if (!this.oauthStore.getClient(client.client_id)) {
this.oauthStore.restoreClient(client, this.config.allowedRedirectHosts);
}

const code = `code-${randomUUID()}`;
this.codes.set(code, {
clientId: client.client_id,
Expand Down
Loading