From be44d5e8d95af10c4e7f3c37d9d329749b191d6e Mon Sep 17 00:00:00 2001 From: xiesong Date: Sat, 20 Jun 2026 14:30:27 +0800 Subject: [PATCH] Persist OAuth state across restarts --- .env.example | 2 + docs/configuration.md | 5 ++ package.json | 2 +- src/config.test.ts | 1 + src/config.ts | 8 ++- src/oauth-provider.test.ts | 73 +++++++++++++++++++++++++ src/oauth-provider.ts | 109 ++++++++++++++++++++++++++++++++++++- 7 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 src/oauth-provider.test.ts diff --git a/.env.example b/.env.example index 1c2cf93..340f62a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index 7107338..d382656 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/package.json b/package.json index f8d3903..24da887 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/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": [], diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4e..3fdf09e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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", diff --git a/src/config.ts b/src/config.ts index bb0526c..94a3326 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, stateDir: string): OAuthConfig { return { ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"), accessTokenTtlSeconds: parsePositiveInteger( @@ -189,6 +189,7 @@ function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined "localhost", "127.0.0.1", ]), + statePath: join(stateDir, "oauth.json"), }; } @@ -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", @@ -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), diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts new file mode 100644 index 0000000..efe7e26 --- /dev/null +++ b/src/oauth-provider.test.ts @@ -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; +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 9bb442f..4da76f5 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -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"; @@ -17,6 +19,7 @@ export interface OAuthConfig { refreshTokenTtlSeconds: number; scopes: string[]; allowedRedirectHosts: string[]; + statePath?: string; } interface AuthorizationCodeRecord { @@ -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 { @@ -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; + 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(); - 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); @@ -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(); private readonly accessTokens = new Map(); private readonly refreshTokens = new Map(); @@ -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( @@ -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( @@ -339,6 +433,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { expiresAt: refreshExpiresAt, resource, }); + this.saveOAuthState(); return { access_token: accessToken, @@ -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(