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: 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/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-provider.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
4 changes: 4 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ assert.throws(
);

assert.equal(loadConfig(baseEnv).oauth.ownerToken, "test-owner-token-that-is-long-enough");
assert.equal(loadConfig(baseEnv).oauth.clientsStorePath, join(emptyConfigDir, "oauth-clients.json"));
assert.equal(loadConfig(baseEnv).oauth.refreshTokensPath, join(emptyConfigDir, "oauth-refresh-tokens.json"));
assert.deepEqual(loadConfig(baseEnv).oauth.scopes, ["devspace"]);
assert.deepEqual(loadConfig(baseEnv).oauth.allowedRedirectHosts, [
"chatgpt.com",
Expand Down Expand Up @@ -161,6 +163,8 @@ writeFileSync(
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.clientsStorePath, join(configDir, "oauth-clients.json"));
assert.equal(fileConfig.oauth.refreshTokensPath, join(configDir, "oauth-refresh-tokens.json"));
assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com");
assert.deepEqual(fileConfig.allowedHosts, [
"localhost",
Expand Down
6 changes: 4 additions & 2 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, configDir: string): OAuthConfig {
return {
ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"),
accessTokenTtlSeconds: parsePositiveInteger(
Expand All @@ -189,6 +189,8 @@ function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined
"localhost",
"127.0.0.1",
]),
clientsStorePath: join(configDir, "oauth-clients.json"),
refreshTokensPath: join(configDir, "oauth-refresh-tokens.json"),
};
}

Expand Down Expand Up @@ -223,7 +225,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.dir),
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-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
import { PersistentOAuthClientsStore, SingleUserOAuthProvider, type OAuthConfig } from "./oauth-provider.js";

const dir = mkdtempSync(join(tmpdir(), "devspace-oauth-clients-"));

try {
const filePath = join(dir, "oauth-clients.json");
const firstStore = new PersistentOAuthClientsStore(["chatgpt.com"], filePath);
const client = firstStore.registerClient({
client_name: "ChatGPT",
redirect_uris: ["https://chatgpt.com/connector/oauth/test"],
});

assert.equal(statSync(filePath).mode & 0o777, 0o600);

const secondStore = new PersistentOAuthClientsStore(["chatgpt.com"], filePath);
assert.deepEqual(secondStore.getClient(client.client_id), client);

const mcpUrl = new URL("http://127.0.0.1:7766/mcp");
const refreshToken = "test-refresh-token-that-is-long-enough";
const refreshTokensPath = join(dir, "oauth-refresh-tokens.json");
const oauthClient: OAuthClientInformationFull = {
client_id: "devspace-test-client",
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris: ["https://chatgpt.com/connector/oauth/test"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
};
const config: OAuthConfig = {
ownerToken: "test-owner-token-that-is-long-enough",
accessTokenTtlSeconds: 60,
refreshTokenTtlSeconds: 3600,
scopes: ["devspace"],
allowedRedirectHosts: ["chatgpt.com"],
clientsStorePath: join(dir, "provider-clients.json"),
refreshTokensPath,
};
writeFileSync(
refreshTokensPath,
JSON.stringify([
{
key: createHash("sha256").update(refreshToken).digest("base64url"),
clientId: oauthClient.client_id,
scopes: ["devspace"],
expiresAt: Math.floor(Date.now() / 1000) + 3600,
resource: mcpUrl.href,
},
]),
{ mode: 0o600 },
);

const provider = new SingleUserOAuthProvider(config, mcpUrl);
const tokens = await provider.exchangeRefreshToken(oauthClient, refreshToken, undefined, mcpUrl);
assert.equal(tokens.token_type, "bearer");
assert.match(tokens.refresh_token ?? "", /^[-_a-zA-Z0-9]+$/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
136 changes: 132 additions & 4 deletions src/oauth-provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { timingSafeEqual, randomBytes, randomUUID, createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } 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 +20,8 @@ export interface OAuthConfig {
refreshTokenTtlSeconds: number;
scopes: string[];
allowedRedirectHosts: string[];
clientsStorePath?: string;
refreshTokensPath?: string;
}

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

interface StoredRefreshTokenRecord {
key: string;
clientId: string;
scopes: string[];
expiresAt: number;
resource?: string;
}

const CODE_TTL_MS = 5 * 60 * 1000;

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

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

constructor(private readonly allowedRedirectHosts: string[]) {}
constructor(
private readonly allowedRedirectHosts: string[],
private readonly filePath = join(homedir(), ".devspace", "oauth-clients.json"),
) {
this.load();
}

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

private load(): void {
if (!existsSync(this.filePath)) return;

const parsed: unknown = JSON.parse(readFileSync(this.filePath, "utf8"));
if (!Array.isArray(parsed)) {
throw new Error(`Invalid OAuth clients file: ${this.filePath}`);
}

for (const client of parsed) {
if (!isStoredClient(client)) continue;
if (!client.redirect_uris.every((uri) => redirectHostAllowed(uri, this.allowedRedirectHosts))) continue;
this.clients.set(client.client_id, client);
}
}

private save(): void {
mkdirSync(dirname(this.filePath), { recursive: true });
const tmpPath = join(dirname(this.filePath), `.oauth-clients.${process.pid}.${randomUUID()}.tmp`);
// ponytail: one JSON file fits this single-user server; use SQLite if multi-process writes matter.
writeFileSync(tmpPath, `${JSON.stringify(Array.from(this.clients.values()), null, 2)}\n`, { mode: 0o600 });
renameSync(tmpPath, this.filePath);
chmodSync(this.filePath, 0o600);
}
}

export class SingleUserOAuthProvider implements OAuthServerProvider {
Expand All @@ -174,13 +217,16 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
private readonly accessTokens = new Map<string, AccessTokenRecord>();
private readonly refreshTokens = new Map<string, RefreshTokenRecord>();
private readonly resourceServerUrl: URL;
private readonly refreshTokensPath: string;

constructor(
private readonly config: OAuthConfig,
resourceServerUrl: URL,
) {
this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl);
this.clientsStore = new InMemoryOAuthClientsStore(config.allowedRedirectHosts);
this.clientsStore = new PersistentOAuthClientsStore(config.allowedRedirectHosts, config.clientsStorePath);
this.refreshTokensPath = config.refreshTokensPath ?? join(homedir(), ".devspace", "oauth-refresh-tokens.json");
this.loadRefreshTokens();
}

async authorize(
Expand Down Expand Up @@ -304,7 +350,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
async revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
const hashed = hashToken(request.token);
this.accessTokens.delete(hashed);
this.refreshTokens.delete(hashed);
if (this.refreshTokens.delete(hashed)) this.saveRefreshTokens();
}

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

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

private loadRefreshTokens(): void {
if (!existsSync(this.refreshTokensPath)) return;

const parsed: unknown = JSON.parse(readFileSync(this.refreshTokensPath, "utf8"));
if (!Array.isArray(parsed)) {
throw new Error(`Invalid OAuth refresh tokens file: ${this.refreshTokensPath}`);
}

const now = Math.floor(Date.now() / 1000);
for (const record of parsed) {
if (!isStoredRefreshTokenRecord(record) || record.expiresAt < now) continue;

const resource = record.resource ? parseUrl(record.resource) : undefined;
if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) continue;

this.refreshTokens.set(record.key, {
token: "",
clientId: record.clientId,
scopes: record.scopes,
expiresAt: record.expiresAt,
resource,
});
}
}

private saveRefreshTokens(): void {
const now = Math.floor(Date.now() / 1000);
const records = Array.from(this.refreshTokens.entries())
.filter(([, record]) => record.expiresAt >= now)
.map(([key, record]): StoredRefreshTokenRecord => ({
key,
clientId: record.clientId,
scopes: record.scopes,
expiresAt: record.expiresAt,
resource: record.resource?.href,
}));

// ponytail: hashed refresh-token JSON is enough for one local user; move to encrypted keychain if this becomes multi-user.
mkdirSync(dirname(this.refreshTokensPath), { recursive: true });
const tmpPath = join(dirname(this.refreshTokensPath), `.oauth-refresh-tokens.${process.pid}.${randomUUID()}.tmp`);
writeFileSync(tmpPath, `${JSON.stringify(records, null, 2)}\n`, { mode: 0o600 });
renameSync(tmpPath, this.refreshTokensPath);
chmodSync(this.refreshTokensPath, 0o600);
}
}

function authorizationFormFields(
Expand All @@ -369,3 +461,39 @@ function authorizationFormFields(
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("base64url");
}

function isStoredClient(value: unknown): value is OAuthClientInformationFull {
if (!value || typeof value !== "object") return false;

const client = value as Partial<OAuthClientInformationFull>;
return (
typeof client.client_id === "string" &&
client.client_id.startsWith("devspace-") &&
typeof client.client_id_issued_at === "number" &&
Array.isArray(client.redirect_uris) &&
client.redirect_uris.every((uri) => typeof uri === "string")
);
}

function isStoredRefreshTokenRecord(value: unknown): value is StoredRefreshTokenRecord {
if (!value || typeof value !== "object") return false;

const record = value as Partial<StoredRefreshTokenRecord>;
return (
typeof record.key === "string" &&
typeof record.clientId === "string" &&
record.clientId.startsWith("devspace-") &&
Array.isArray(record.scopes) &&
record.scopes.every((scope) => typeof scope === "string") &&
typeof record.expiresAt === "number" &&
(record.resource === undefined || typeof record.resource === "string")
);
}

function parseUrl(value: string): URL | undefined {
try {
return new URL(value);
} catch {
return undefined;
}
}