diff --git a/package.json b/package.json index f8d3903..d4e382f 100644 --- a/package.json +++ b/package.json @@ -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": [], diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4e..04c4ed1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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", @@ -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", diff --git a/src/config.ts b/src/config.ts index bb0526c..2d94150 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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( @@ -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"), }; } @@ -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, diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts new file mode 100644 index 0000000..8553302 --- /dev/null +++ b/src/oauth-provider.test.ts @@ -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 }); +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 9bb442f..0adc304 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -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"; @@ -17,6 +20,8 @@ export interface OAuthConfig { refreshTokenTtlSeconds: number; scopes: string[]; allowedRedirectHosts: string[]; + clientsStorePath?: string; + refreshTokensPath?: string; } interface AuthorizationCodeRecord { @@ -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 { @@ -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(); - 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); @@ -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 { @@ -174,13 +217,16 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { private readonly accessTokens = new Map(); private readonly refreshTokens = new Map(); 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( @@ -304,7 +350,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { async revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise { const hashed = hashToken(request.token); this.accessTokens.delete(hashed); - this.refreshTokens.delete(hashed); + if (this.refreshTokens.delete(hashed)) this.saveRefreshTokens(); } private validCodeRecord( @@ -339,6 +385,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { expiresAt: refreshExpiresAt, resource, }); + this.saveRefreshTokens(); return { access_token: accessToken, @@ -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( @@ -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; + 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; + 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; + } +}