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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ DEVSPACE_LOG_ASSETS=0
DEVSPACE_LOG_TOOL_CALLS=1
DEVSPACE_LOG_SHELL_COMMANDS=0
# DEVSPACE_TRUST_PROXY=1
# OAuth client registrations and refresh tokens are stored in
# $DEVSPACE_STATE_DIR/oauth.json. Access tokens remain in memory only.
# DEVSPACE_STATE_DIR=/home/waishnav/.local/share/devspace
# DEVSPACE_WORKTREE_ROOT=/home/waishnav/.devspace/worktrees
# DEVSPACE_AUTO_LOAD_AGENTS_MD=1
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ DevSpace uses a single-user OAuth approval flow.
| `DEVSPACE_OAUTH_SCOPES` | `devspace` |
| `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `chatgpt.com,localhost,127.0.0.1` |

Registered OAuth clients and refresh tokens are persisted in
`$DEVSPACE_STATE_DIR/oauth.json`. Access tokens and authorization codes
remain in memory only. After a restart, clients can use their refresh token
to obtain a new access token without re-registering.

MCP clients discover metadata from:

```text
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"build:app": "vite build",
"dev": "node scripts/dev-server.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts",
"test": "tsx src/config.test.ts && tsx src/oauth-provider.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
1 change: 1 addition & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ assert.throws(
);

assert.equal(loadConfig(baseEnv).oauth.ownerToken, "test-owner-token-that-is-long-enough");
assert.match(loadConfig(baseEnv).oauth.statePath ?? "", /oauth\.json$/);
assert.deepEqual(loadConfig(baseEnv).oauth.scopes, ["devspace"]);
assert.deepEqual(loadConfig(baseEnv).oauth.allowedRedirectHosts, [
"chatgpt.com",
Expand Down
8 changes: 5 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ function parseRequiredSecret(value: string | undefined, name: string): string {
return secret;
}

function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined): OAuthConfig {
function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined, stateDir: string): OAuthConfig {
return {
ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"),
accessTokenTtlSeconds: parsePositiveInteger(
Expand All @@ -189,6 +189,7 @@ function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined
"localhost",
"127.0.0.1",
]),
statePath: join(stateDir, "oauth.json"),
};
}

Expand All @@ -211,6 +212,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const publicBaseUrl = parsePublicBaseUrl(
env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port),
);
const stateDir = resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir()));
const derivedAllowedHosts = [
"localhost",
"127.0.0.1",
Expand All @@ -223,14 +225,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
return {
host,
port,
oauth: parseOAuthConfig(env, files.auth.ownerToken),
oauth: parseOAuthConfig(env, files.auth.ownerToken, stateDir),
allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots),
allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts),
publicBaseUrl,
minimalTools: parseMinimalTools(env),
toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING),
widgets: parseWidgetMode(env.DEVSPACE_WIDGETS),
stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())),
stateDir,
worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())),
skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS),
skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS),
Expand Down
73 changes: 73 additions & 0 deletions src/oauth-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import { SingleUserOAuthProvider, type OAuthConfig } from "./oauth-provider.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";

const root = mkdtempSync(join(tmpdir(), "devspace-oauth-provider-test-"));
const statePath = join(root, "oauth.json");
const resourceServerUrl = new URL("https://devspace.example.com/mcp");
const config: OAuthConfig = {
ownerToken: "owner-token-that-is-long-enough",
accessTokenTtlSeconds: 3600,
refreshTokenTtlSeconds: 2592000,
scopes: ["devspace"],
allowedRedirectHosts: ["localhost"],
statePath,
};

try {
const firstProvider = new SingleUserOAuthProvider(config, resourceServerUrl);
const client = firstProvider.clientsStore.registerClient({
client_name: "test client",
redirect_uris: ["http://localhost/callback"],
scope: "devspace",
});
const issueTokens = firstProvider["issueTokens"] as (
clientId: string,
scopes: string[],
resource?: URL,
) => OAuthTokens;
const firstTokens = issueTokens.call(firstProvider, client.client_id, ["devspace"], resourceServerUrl);

const savedState = JSON.parse(readFileSync(statePath, "utf8"));
assert.equal(savedState.clients.length, 1);
assert.equal(savedState.accessTokens, undefined);
assert.equal(savedState.refreshTokens.length, 1);
assert.equal(savedState.refreshTokens[0].token, undefined);
assert.equal(savedState.refreshTokens[0].resource, resourceServerUrl.href);

const secondProvider = new SingleUserOAuthProvider(config, resourceServerUrl);
const persistedClient = secondProvider.clientsStore.getClient(client.client_id);
assert.equal(persistedClient?.client_id, client.client_id);

await assert.rejects(
() => secondProvider.verifyAccessToken(firstTokens.access_token),
InvalidTokenError,
);

const secondTokens = await secondProvider.exchangeRefreshToken(
client,
assertString(firstTokens.refresh_token),
undefined,
resourceServerUrl,
);
assert.equal(Boolean(secondTokens.refresh_token), true);
assert.notEqual(secondTokens.refresh_token, firstTokens.refresh_token);

const rotatedState = JSON.parse(readFileSync(statePath, "utf8"));
assert.equal(rotatedState.clients.length, 1);
assert.equal(rotatedState.accessTokens, undefined);
assert.equal(rotatedState.refreshTokens.length, 1);
} finally {
rmSync(root, { recursive: true, force: true });
}

function assertString(value: string | undefined): string {
if (typeof value !== "string") {
throw new Error("Expected string value");
}
return value;
}
109 changes: 106 additions & 3 deletions src/oauth-provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { timingSafeEqual, randomBytes, randomUUID, createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { Response } from "express";
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
import type { OAuthServerProvider, AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
Expand All @@ -17,6 +19,7 @@ export interface OAuthConfig {
refreshTokenTtlSeconds: number;
scopes: string[];
allowedRedirectHosts: string[];
statePath?: string;
}

interface AuthorizationCodeRecord {
Expand All @@ -41,6 +44,19 @@ interface RefreshTokenRecord {
resource?: URL;
}

interface StoredTokenRecord {
tokenHash: string;
clientId: string;
scopes: string[];
expiresAt: number;
resource?: string;
}

interface StoredOAuthState {
clients: OAuthClientInformationFull[];
refreshTokens: StoredTokenRecord[];
}

const CODE_TTL_MS = 5 * 60 * 1000;

function randomToken(): string {
Expand Down Expand Up @@ -138,10 +154,64 @@ function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boole
return allowedHosts.includes(parsed.hostname);
}

function emptyOAuthState(): StoredOAuthState {
return { clients: [], refreshTokens: [] };
}

function readOAuthState(statePath: string | undefined): StoredOAuthState {
if (!statePath || !existsSync(statePath)) return emptyOAuthState();

try {
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as Partial<StoredOAuthState>;
return {
clients: Array.isArray(parsed.clients) ? parsed.clients : [],
refreshTokens: Array.isArray(parsed.refreshTokens) ? parsed.refreshTokens : [],
};
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to read OAuth state at ${statePath}: ${reason}`);
}
}

function writeOAuthState(
statePath: string | undefined,
clients: OAuthClientInformationFull[],
refreshTokens: Array<[string, RefreshTokenRecord]>,
): void {
if (!statePath) return;

mkdirSync(dirname(statePath), { recursive: true });
const state = {
version: 1,
clients,
refreshTokens: refreshTokens.map(([tokenHash, record]) => ({
tokenHash,
clientId: record.clientId,
scopes: record.scopes,
expiresAt: record.expiresAt,
resource: record.resource?.href,
})),
};
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
chmodSync(statePath, 0o600);
}

function parseStoredResource(resource: string | undefined): URL | undefined {
return resource ? new URL(resource) : undefined;
}

export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore {
private readonly clients = new Map<string, OAuthClientInformationFull>();

constructor(private readonly allowedRedirectHosts: string[]) {}
constructor(
private readonly allowedRedirectHosts: string[],
initialClients: OAuthClientInformationFull[] = [],
private readonly onChange: () => void = () => {},
) {
for (const client of initialClients) {
this.clients.set(client.client_id, client);
}
}

getClient(clientId: string): OAuthClientInformationFull | undefined {
return this.clients.get(clientId);
Expand All @@ -164,12 +234,17 @@ export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore {
response_types: client.response_types ?? ["code"],
};
this.clients.set(registered.client_id, registered);
this.onChange();
return registered;
}

dumpClients(): OAuthClientInformationFull[] {
return Array.from(this.clients.values());
}
}

export class SingleUserOAuthProvider implements OAuthServerProvider {
readonly clientsStore: OAuthRegisteredClientsStore;
readonly clientsStore: InMemoryOAuthClientsStore;
private readonly codes = new Map<string, AuthorizationCodeRecord>();
private readonly accessTokens = new Map<string, AccessTokenRecord>();
private readonly refreshTokens = new Map<string, RefreshTokenRecord>();
Expand All @@ -180,7 +255,25 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
resourceServerUrl: URL,
) {
this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl);
this.clientsStore = new InMemoryOAuthClientsStore(config.allowedRedirectHosts);
const state = readOAuthState(config.statePath);
this.clientsStore = new InMemoryOAuthClientsStore(config.allowedRedirectHosts, state.clients, () => {
this.saveOAuthState();
});

const now = Math.floor(Date.now() / 1000);
for (const record of state.refreshTokens) {
if (!record.tokenHash || !record.clientId || !Array.isArray(record.scopes) || !record.expiresAt) continue;
if (record.expiresAt < now) continue;

this.refreshTokens.set(record.tokenHash, {
token: record.tokenHash,
clientId: record.clientId,
scopes: record.scopes,
expiresAt: record.expiresAt,
resource: parseStoredResource(record.resource),
});
}
this.saveOAuthState();
}

async authorize(
Expand Down Expand Up @@ -305,6 +398,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
const hashed = hashToken(request.token);
this.accessTokens.delete(hashed);
this.refreshTokens.delete(hashed);
this.saveOAuthState();
}

private validCodeRecord(
Expand Down Expand Up @@ -339,6 +433,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
expiresAt: refreshExpiresAt,
resource,
});
this.saveOAuthState();

return {
access_token: accessToken,
Expand All @@ -348,6 +443,14 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
scope: scopes.join(" "),
};
}

private saveOAuthState(): void {
writeOAuthState(
this.config.statePath,
this.clientsStore.dumpClients(),
Array.from(this.refreshTokens.entries()),
);
}
}

function authorizationFormFields(
Expand Down