diff --git a/docs/adr/0011-oauth-refresh-tokens-use-rotating-families.md b/docs/adr/0011-oauth-refresh-tokens-use-rotating-families.md new file mode 100644 index 00000000..4c544c24 --- /dev/null +++ b/docs/adr/0011-oauth-refresh-tokens-use-rotating-families.md @@ -0,0 +1,11 @@ +--- +status: accepted +--- + +# OAuth Refresh Tokens Use Rotating Families + +1MCP issues refresh tokens only to registered clients that request the `refresh_token` grant. Each approved authorization creates an independent, client-, resource-, and scope-bound Refresh Token Family that persists in its Runtime Scope for a fixed 30 days. Every successful refresh rotates a single-use opaque token; only token digests and family lineage are stored. + +Because public PKCE clients are not sender-constrained, reuse of any consumed family member revokes its family and every access token issued from it. There is no retry grace period: concurrent refreshes permit one success, and later use is replay. A normal refresh may narrow the new access token's scopes and leaves earlier access tokens valid until their own expiry; explicit access-token revocation remains local to that token. + +This chooses RFC 9700 rotation and strong replay containment over retry availability. A lost response or concurrent retry can force reauthorization, but the runtime never permits two valid successors or extends the family beyond its original lifetime. diff --git a/docs/en/guide/advanced/authentication.md b/docs/en/guide/advanced/authentication.md index 84d16668..7d6e3003 100644 --- a/docs/en/guide/advanced/authentication.md +++ b/docs/en/guide/advanced/authentication.md @@ -27,6 +27,12 @@ npx -y @1mcp/agent --config mcp.json --enable-auth This will activate the OAuth 2.1 endpoints and require authentication for all incoming requests. +## Refresh Token Rotation + +Registered clients receive a refresh token only when they request the `refresh_token` grant. Refresh tokens are single-use and rotate on every successful exchange. Each family has a fixed 30-day lifetime, remains bound to its original client and resource, and permits only equal or narrower scopes. + +Reusing any consumed refresh token revokes the entire family and every access token issued from it. Concurrent refreshes therefore allow exactly one successful rotation; clients must reauthorize after a replay response or a lost response that causes a retry. + ## OAuth Management Dashboard Once authentication is enabled, you can use the OAuth Management Dashboard to manage the authorization flow with your backend services. The dashboard is available at the `/oauth` endpoint of your agent's URL (e.g., `http://localhost:3050/oauth`). diff --git a/docs/zh/guide/advanced/authentication.md b/docs/zh/guide/advanced/authentication.md index f43041cf..d26cc5ab 100644 --- a/docs/zh/guide/advanced/authentication.md +++ b/docs/zh/guide/advanced/authentication.md @@ -27,6 +27,12 @@ npx -y @1mcp/agent --config mcp.json --enable-auth 这将激活 OAuth 2.1 端点,并要求对所有传入请求进行身份验证。 +## 刷新令牌轮换 + +只有请求 `refresh_token` 授权类型的已注册客户端才会收到刷新令牌。刷新令牌只能使用一次,每次成功交换都会轮换。每个令牌家族的生命周期固定为 30 天,并始终绑定到原始客户端和资源;后续请求只能保持或缩小原始作用域。 + +重复使用任何已消费的刷新令牌会撤销整个家族及其签发的所有访问令牌。因此,并发刷新只允许一次成功轮换;发生重放响应,或响应丢失后重试导致重放时,客户端必须重新授权。 + ## OAuth 管理仪表板 启用身份验证后,您可以使用 OAuth 管理仪表板来管理与后端服务的授权流程。该仪表板可在代理 URL 的 `/oauth` 端点处获得(例如,`http://localhost:3050/oauth`)。 diff --git a/src/auth/oauthAuthorizationFlow.ts b/src/auth/oauthAuthorizationFlow.ts index 7c9f5706..9f46336b 100644 --- a/src/auth/oauthAuthorizationFlow.ts +++ b/src/auth/oauthAuthorizationFlow.ts @@ -15,7 +15,14 @@ export interface OAuthAuthorizationFlowStorage { getClient(clientId: string): unknown | null | undefined; processConsentApproval(authRequestId: string, selectedScopes: string[]): Promise<{ redirectUrl: URL }>; processConsentDenial(authRequestId: string): Promise; - createSessionWithId(tokenId: string, clientId: string, resource: string, scopes: string[], ttlMs: number): string; + createSessionWithId( + tokenId: string, + clientId: string, + resource: string, + scopes: string[], + ttlMs: number, + refreshFamilyId?: string, + ): string; } export interface OAuthAuthorizationFlowStorageService { @@ -26,7 +33,14 @@ export interface OAuthAuthorizationFlowStorageService { processConsentApproval(authRequestId: string, selectedScopes: string[]): Promise<{ redirectUrl: URL }>; processConsentDenial(authRequestId: string): Promise; sessionRepository: { - createWithId(tokenId: string, clientId: string, resource: string, scopes: string[], ttlMs: number): string; + createWithId( + tokenId: string, + clientId: string, + resource: string, + scopes: string[], + ttlMs: number, + refreshFamilyId?: string, + ): string; }; } @@ -382,8 +396,8 @@ export function createOAuthAuthorizationFlowFromStorage( processConsentApproval: (authRequestId, selectedScopes) => storage.processConsentApproval(authRequestId, selectedScopes), processConsentDenial: (authRequestId) => storage.processConsentDenial(authRequestId), - createSessionWithId: (tokenId, clientId, resource, scopes, ttlMs) => - storage.sessionRepository.createWithId(tokenId, clientId, resource, scopes, ttlMs), + createSessionWithId: (tokenId, clientId, resource, scopes, ttlMs, refreshFamilyId) => + storage.sessionRepository.createWithId(tokenId, clientId, resource, scopes, ttlMs, refreshFamilyId), }, }); } diff --git a/src/auth/sdkOAuthServerProvider.refresh.test.ts b/src/auth/sdkOAuthServerProvider.refresh.test.ts new file mode 100644 index 00000000..69eaaf8e --- /dev/null +++ b/src/auth/sdkOAuthServerProvider.refresh.test.ts @@ -0,0 +1,330 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + InvalidGrantError, + InvalidScopeError, + InvalidTargetError, +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; + +import { RefreshTokenFamilyDataSchema } from '@src/auth/sessionTypes.js'; +import { AUTH_CONFIG } from '@src/constants.js'; +import { AgentConfigManager } from '@src/core/server/agentConfig.js'; +import logger from '@src/logger/logger.js'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SDKOAuthServerProvider } from './sdkOAuthServerProvider.js'; +import { FileStorageService } from './storage/fileStorageService.js'; + +const CLIENT: OAuthClientInformationFull = { + client_id: 'refresh-client', + redirect_uris: ['http://127.0.0.1:3000/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', +}; +const OTHER_CLIENT: OAuthClientInformationFull = { ...CLIENT, client_id: 'other-client' }; +const ACCESS_ONLY_CLIENT: OAuthClientInformationFull = { + ...CLIENT, + client_id: 'access-only-client', + grant_types: ['authorization_code'], +}; +const RESOURCE = 'https://resource.example/mcp'; +const SCOPES = ['tag:alpha', 'tag:beta']; + +describe('SDKOAuthServerProvider refresh token families', () => { + let provider: SDKOAuthServerProvider; + let tempDir: string; + let originalAuthEnabled: boolean; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), '1mcp-refresh-family-')); + provider = new SDKOAuthServerProvider(tempDir, 'runtime-scope-a'); + const configManager = AgentConfigManager.getInstance(); + originalAuthEnabled = configManager.get('features').auth; + configManager.get('features').auth = true; + }); + + afterEach(() => { + provider.shutdown(); + AgentConfigManager.getInstance().get('features').auth = originalAuthEnabled; + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('issues refresh tokens only to opted-in clients and persists only their SHA-256 digest', async () => { + const renewable = await exchangeAuthorizationCode(provider, CLIENT); + const accessOnly = await exchangeAuthorizationCode(provider, ACCESS_ONLY_CLIENT); + + expect(renewable.refresh_token).toMatch(/^rt-[A-Za-z0-9_-]{43}$/); + expect(accessOnly).not.toHaveProperty('refresh_token'); + + const familyFiles = listFamilyFiles(tempDir); + expect(familyFiles).toHaveLength(1); + const stored = fs.readFileSync(familyFiles[0], 'utf8'); + expect(stored).not.toContain(renewable.refresh_token!); + expect(JSON.parse(stored)).toMatchObject({ + runtimeScopeId: 'runtime-scope-a', + clientId: CLIENT.client_id, + scopeCeiling: SCOPES, + resource: RESOURCE, + status: 'active', + currentTokenDigest: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + }); + + it('persists families across provider restarts and rotation does not extend their fixed expiry', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const before = readSoleFamily(tempDir); + provider.shutdown(); + provider = new SDKOAuthServerProvider(tempDir, 'runtime-scope-a'); + + const rotated = await provider.exchangeRefreshToken(CLIENT, initial.refresh_token!); + const after = readSoleFamily(tempDir); + + expect(rotated.refresh_token).toMatch(/^rt-/); + expect(rotated.refresh_token).not.toBe(initial.refresh_token); + expect(after.createdAt).toBe(before.createdAt); + expect(after.expires).toBe(before.expires); + expect(after.expires - after.createdAt).toBe(AUTH_CONFIG.SERVER.REFRESH_FAMILY.TTL_MS); + expect(after.consumedTokenDigests).toHaveLength(1); + }); + + it('fails closed and cleans persisted family state after the fixed expiry', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + for (const filePath of [...listFamilyFiles(tempDir), ...listLookupFiles(tempDir)]) { + const record = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + fs.writeFileSync(filePath, JSON.stringify({ ...record, expires: Date.now() - 1 })); + } + + await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).rejects.toBeInstanceOf( + InvalidGrantError, + ); + await expect(provider.verifyAccessToken(initial.access_token)).rejects.toThrow('Invalid or expired access token'); + expect(listFamilyFiles(tempDir)).toHaveLength(0); + expect(listLookupFiles(tempDir)).toHaveLength(0); + }); + + it('does not consume the current refresh token when access-session persistence fails', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const createSession = vi + .spyOn(provider.oauthStorage.sessionRepository, 'createRefreshFamilyAccessSession') + .mockImplementation(() => { + throw new Error('session persistence failed'); + }); + + await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).rejects.toThrow( + 'session persistence failed', + ); + + createSession.mockRestore(); + await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).resolves.toMatchObject({ + refresh_token: expect.stringMatching(/^rt-/), + }); + }); + + it('does not consume the current refresh token when the family commit fails', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const originalWriteData = FileStorageService.prototype.writeDataDurable; + let failedFamilyCommit = false; + const writeData = vi.spyOn(FileStorageService.prototype, 'writeDataDurable').mockImplementation(function ( + this: FileStorageService, + filePrefix: string, + id: string, + data: Parameters[2], + ) { + if (filePrefix === AUTH_CONFIG.SERVER.REFRESH_FAMILY.FILE_PREFIX && !failedFamilyCommit) { + failedFamilyCommit = true; + throw new Error('family commit failed'); + } + return originalWriteData.call(this, filePrefix, id, data); + }); + + await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).rejects.toThrow('family commit failed'); + writeData.mockRestore(); + + await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).resolves.toMatchObject({ + refresh_token: expect.stringMatching(/^rt-/), + }); + }); + + it('preserves the original scope ceiling and resource while allowing equal or narrower access', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + + await expect( + provider.exchangeRefreshToken(CLIENT, initial.refresh_token!, ['tag:alpha', 'tag:outside']), + ).rejects.toBeInstanceOf(InvalidScopeError); + await expect( + provider.exchangeRefreshToken(CLIENT, initial.refresh_token!, undefined, new URL('https://other.example/mcp')), + ).rejects.toBeInstanceOf(InvalidTargetError); + + const narrowed = await provider.exchangeRefreshToken( + CLIENT, + initial.refresh_token!, + ['tag:alpha'], + new URL(RESOURCE), + ); + expect(narrowed.scope).toBe('tag:alpha'); + + const restored = await provider.exchangeRefreshToken(CLIENT, narrowed.refresh_token!); + expect(restored.scope).toBe(SCOPES.join(' ')); + expect(readSoleFamily(tempDir).scopeCeiling).toEqual(SCOPES); + }); + + it('allows exactly one concurrent rotation and replay revokes the family and every associated access token', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const attempts = await Promise.allSettled([ + provider.exchangeRefreshToken(CLIENT, initial.refresh_token!), + provider.exchangeRefreshToken(CLIENT, initial.refresh_token!), + ]); + + expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1); + const rejection = attempts.find((attempt) => attempt.status === 'rejected') as PromiseRejectedResult; + expect(rejection.reason).toBeInstanceOf(InvalidGrantError); + expect(readSoleFamily(tempDir).status).toBe('revoked'); + + const successful = ( + attempts.find((attempt) => attempt.status === 'fulfilled') as PromiseFulfilledResult + ).value; + await expect(provider.verifyAccessToken(initial.access_token)).rejects.toThrow('Invalid or expired access token'); + await expect(provider.verifyAccessToken(successful.access_token)).rejects.toThrow( + 'Invalid or expired access token', + ); + await expect(provider.exchangeRefreshToken(CLIENT, successful.refresh_token!)).rejects.toBeInstanceOf( + InvalidGrantError, + ); + }); + + it('keeps family state bounded while older consumed-token lookups retain replay containment', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const first = await provider.exchangeRefreshToken(CLIENT, initial.refresh_token!); + const second = await provider.exchangeRefreshToken(CLIENT, first.refresh_token!); + await provider.exchangeRefreshToken(CLIENT, second.refresh_token!); + + expect(readSoleFamily(tempDir).consumedTokenDigests).toHaveLength(1); + await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).rejects.toBeInstanceOf( + InvalidGrantError, + ); + expect(readSoleFamily(tempDir).status).toBe('revoked'); + }); + + it('isolates refresh families by Runtime Scope even when storage is shared', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const otherScopeProvider = new SDKOAuthServerProvider(tempDir, 'runtime-scope-b'); + try { + await expect(otherScopeProvider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).rejects.toBeInstanceOf( + InvalidGrantError, + ); + await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).resolves.toMatchObject({ + refresh_token: expect.stringMatching(/^rt-/), + }); + } finally { + otherScopeProvider.shutdown(); + } + }); + + it('propagates replay-cascade deletion failures while revoked family state blocks residual sessions', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const rotated = await provider.exchangeRefreshToken(CLIENT, initial.refresh_token!); + const originalUnlinkSync = fs.unlinkSync; + const unlink = vi.spyOn(fs, 'unlinkSync').mockImplementation((filePath) => { + if (String(filePath).includes(AUTH_CONFIG.SERVER.SESSION.FILE_PREFIX)) { + throw new Error('session deletion failed'); + } + return originalUnlinkSync(filePath); + }); + + await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).rejects.toThrow( + 'Failed to revoke access sessions for refresh token family', + ); + unlink.mockRestore(); + await expect(provider.verifyAccessToken(initial.access_token)).rejects.toThrow('Invalid or expired access token'); + await expect(provider.verifyAccessToken(rotated.access_token)).rejects.toThrow('Invalid or expired access token'); + }); + + it('does not mutate a rightful family when another client presents its refresh token', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + + await expect(provider.exchangeRefreshToken(OTHER_CLIENT, initial.refresh_token!)).rejects.toBeInstanceOf( + InvalidGrantError, + ); + const rightful = await provider.exchangeRefreshToken(CLIENT, initial.refresh_token!); + expect(rightful.refresh_token).toMatch(/^rt-/); + }); + + it('keeps earlier access tokens valid on normal rotation and applies token-specific revocation semantics', async () => { + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const rotated = await provider.exchangeRefreshToken(CLIENT, initial.refresh_token!); + + await expect(provider.verifyAccessToken(initial.access_token)).resolves.toMatchObject({ + clientId: CLIENT.client_id, + }); + await provider.revokeToken(CLIENT, { token: initial.access_token }); + await expect(provider.verifyAccessToken(initial.access_token)).rejects.toThrow('Invalid or expired access token'); + await expect(provider.verifyAccessToken(rotated.access_token)).resolves.toMatchObject({ + clientId: CLIENT.client_id, + }); + + const next = await provider.exchangeRefreshToken(CLIENT, rotated.refresh_token!); + await provider.revokeToken(CLIENT, { token: next.refresh_token! }); + await expect(provider.verifyAccessToken(rotated.access_token)).rejects.toThrow('Invalid or expired access token'); + await expect(provider.verifyAccessToken(next.access_token)).rejects.toThrow('Invalid or expired access token'); + await expect(provider.exchangeRefreshToken(CLIENT, next.refresh_token!)).rejects.toBeInstanceOf(InvalidGrantError); + + await expect(provider.revokeToken(CLIENT, { token: 'unknown-token' })).resolves.toBeUndefined(); + }); + + it('never passes refresh bearer values to the logger', async () => { + const debug = vi.spyOn(logger, 'debug'); + const info = vi.spyOn(logger, 'info'); + const warn = vi.spyOn(logger, 'warn'); + const error = vi.spyOn(logger, 'error'); + const initial = await exchangeAuthorizationCode(provider, CLIENT); + const rotated = await provider.exchangeRefreshToken(CLIENT, initial.refresh_token!); + await provider.revokeToken(CLIENT, { token: rotated.refresh_token! }); + + const logged = JSON.stringify([...debug.mock.calls, ...info.mock.calls, ...warn.mock.calls, ...error.mock.calls]); + expect(logged).not.toContain(initial.refresh_token!); + expect(logged).not.toContain(rotated.refresh_token!); + }); +}); + +async function exchangeAuthorizationCode( + provider: SDKOAuthServerProvider, + client: OAuthClientInformationFull, +): Promise { + const code = provider.oauthStorage.authCodeRepository.create( + client.client_id, + client.redirect_uris[0], + RESOURCE, + SCOPES, + 60_000, + 'challenge', + ); + return provider.exchangeAuthorizationCode(client, code, undefined, client.redirect_uris[0], new URL(RESOURCE)); +} + +function listFamilyFiles(tempDir: string): string[] { + const serverDir = path.join(tempDir, AUTH_CONFIG.SERVER.STORAGE.DIR, AUTH_CONFIG.SERVER.SESSION.SUBDIR); + return fs + .readdirSync(serverDir) + .filter((fileName) => fileName.startsWith(AUTH_CONFIG.SERVER.REFRESH_FAMILY.FILE_PREFIX)) + .map((fileName) => path.join(serverDir, fileName)); +} + +function listLookupFiles(tempDir: string): string[] { + const serverDir = path.join(tempDir, AUTH_CONFIG.SERVER.STORAGE.DIR, AUTH_CONFIG.SERVER.SESSION.SUBDIR); + return fs + .readdirSync(serverDir) + .filter((fileName) => fileName.startsWith(AUTH_CONFIG.SERVER.REFRESH_FAMILY.LOOKUP_FILE_PREFIX)) + .map((fileName) => path.join(serverDir, fileName)); +} + +function readSoleFamily(tempDir: string) { + const familyFiles = listFamilyFiles(tempDir); + expect(familyFiles).toHaveLength(1); + return RefreshTokenFamilyDataSchema.parse(JSON.parse(fs.readFileSync(familyFiles[0], 'utf8'))); +} diff --git a/src/auth/sdkOAuthServerProvider.test.ts b/src/auth/sdkOAuthServerProvider.test.ts index 9a7f52e3..6ade92b3 100644 --- a/src/auth/sdkOAuthServerProvider.test.ts +++ b/src/auth/sdkOAuthServerProvider.test.ts @@ -189,11 +189,61 @@ describe('SDKOAuthProvider', () => { expect(response.set).toHaveBeenCalledWith( 'Content-Security-Policy', - "default-src 'none'; form-action 'self'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none';", + "default-src 'none'; form-action 'self' http://localhost:3000; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none';", ); expect(response.set).toHaveBeenCalledWith('Content-Type', 'text/html'); expect(response.send).toHaveBeenCalledTimes(1); expect(response.removeHeader).not.toHaveBeenCalledWith('Content-Security-Policy'); }); + + it('discloses renewable access only for clients registered for refresh tokens', () => { + const refreshClient: OAuthClientInformationFull = { + client_id: 'refresh-client', + redirect_uris: ['http://127.0.0.1:3000/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + const accessOnlyClient: OAuthClientInformationFull = { + ...refreshClient, + client_id: 'access-only-client', + grant_types: ['authorization_code'], + }; + + expect(provider['generateConsentPageHtml'](refreshClient, 'request-id', [], [])).toContain( + 'renew this access for up to 30 days', + ); + expect(provider['generateConsentPageHtml'](accessOnlyClient, 'request-id', [], [])).not.toContain( + 'renew this access', + ); + }); + + it('does not add non-loopback redirect origins to form-action', async () => { + const client: OAuthClientInformationFull = { + client_id: 'web-client', + redirect_uris: ['https://client.example/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + const response = { set: vi.fn(), send: vi.fn() } as any; + + await provider['renderConsentPage']( + client, + { + redirectUri: 'https://client.example/callback', + codeChallenge: 'challenge', + scopes: [], + }, + [], + [], + response, + ); + + expect(response.set).toHaveBeenCalledWith( + 'Content-Security-Policy', + "default-src 'none'; form-action 'self'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none';", + ); + }); }); }); diff --git a/src/auth/sdkOAuthServerProvider.ts b/src/auth/sdkOAuthServerProvider.ts index eebd63df..9e345ae0 100644 --- a/src/auth/sdkOAuthServerProvider.ts +++ b/src/auth/sdkOAuthServerProvider.ts @@ -1,6 +1,11 @@ import { randomUUID } from 'node:crypto'; import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; +import { + InvalidGrantError, + InvalidScopeError, + InvalidTargetError, +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; import type { @@ -11,6 +16,7 @@ import type { import { McpConfigManager } from '@src/config/mcpConfigManager.js'; import { AUTH_CONFIG } from '@src/constants.js'; +import { RuntimeIdentityService } from '@src/core/runtime/runtimeIdentityService.js'; import { AgentConfigManager } from '@src/core/server/agentConfig.js'; import logger from '@src/logger/logger.js'; import { escapeHtml } from '@src/utils/validation/sanitization.js'; @@ -25,8 +31,9 @@ import type { Response } from 'express'; import { OAuthStorageService } from './storage/oauthStorageService.js'; -const OAUTH_CONSENT_PAGE_CSP = - "default-src 'none'; form-action 'self'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none';"; +const OAUTH_CONSENT_PAGE_CSP_SUFFIX = + "style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none';"; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); /** * File-based OAuth clients store implementation using the new repository architecture @@ -84,8 +91,10 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { private configManager: AgentConfigManager; private _clientsStore: OAuthRegisteredClientsStore; - constructor(sessionStoragePath?: string) { - this.oauthStorage = new OAuthStorageService(sessionStoragePath); + constructor(sessionStoragePath?: string, runtimeScopeId?: string) { + const scopeId = + runtimeScopeId ?? new RuntimeIdentityService({ storageDir: sessionStoragePath }).getRuntimeScopeId(); + this.oauthStorage = new OAuthStorageService(sessionStoragePath, scopeId); this.configManager = AgentConfigManager.getInstance(); this._clientsStore = new FileBasedClientsStore(this.oauthStorage); } @@ -180,7 +189,7 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { const scopeTags = scopesToTags(requestedScopes); const consentPageHtml = this.generateConsentPageHtml(client, authRequestId, scopeTags, availableTags); - res.set('Content-Security-Policy', OAUTH_CONSENT_PAGE_CSP); + res.set('Content-Security-Policy', createConsentPageCsp(client, params.redirectUri)); res.set('Content-Type', 'text/html'); res.send(consentPageHtml); } @@ -240,6 +249,9 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { ): string { const clientName = escapeHtml(client.client_name || client.client_id); const escapedAuthRequestId = escapeHtml(authRequestId); + const renewableAccessNotice = client.grant_types?.includes('refresh_token') + ? '

This application can renew this access for up to 30 days.

' + : ''; return ` @@ -276,6 +288,7 @@ export class SDKOAuthServerProvider implements OAuthServerProvider {
Security Notice: Only grant access to server groups that this application needs. + ${renewableAccessNotice}
@@ -327,7 +340,7 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { const codeData = this.oauthStorage.authCodeRepository.get(authorizationCode); if (!codeData || codeData.clientId !== client.client_id) { - throw new Error('Invalid authorization code'); + throw new InvalidGrantError('Invalid authorization code'); } return codeData.codeChallenge || ''; @@ -354,22 +367,22 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { const codeData = this.oauthStorage.authCodeRepository.get(authorizationCode); if (!codeData) { - throw new Error('Invalid or expired authorization code'); + throw new InvalidGrantError('Invalid or expired authorization code'); } // Validate client ID if (codeData.clientId !== client.client_id) { - throw new Error('Client ID mismatch'); + throw new InvalidGrantError('Invalid or expired authorization code'); } // Validate redirect URI if provided if (redirectUri && codeData.redirectUri !== redirectUri) { - throw new Error('Redirect URI mismatch'); + throw new InvalidGrantError('Redirect URI mismatch'); } // Validate resource if provided if (resource && codeData.resource && codeData.resource !== resource.toString()) { - throw new Error('Resource mismatch'); + throw new InvalidTargetError('Resource mismatch'); } // Delete the authorization code (one-time use) @@ -380,20 +393,40 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { const accessToken = AUTH_CONFIG.SERVER.TOKEN.ID_PREFIX + tokenId; const ttlMs = this.configManager.get('auth').oauthTokenTtlMs; - // Store session for token validation - this.oauthStorage.sessionRepository.createWithId( - tokenId, - client.client_id, - codeData.resource || '', - codeData.scopes, - ttlMs, - ); + const refreshFamily = client.grant_types?.includes('refresh_token') + ? await this.oauthStorage.refreshTokenFamilyRepository.create( + client.client_id, + codeData.scopes, + codeData.resource || '', + tokenId, + (familyId) => + this.oauthStorage.sessionRepository.createRefreshFamilyAccessSession({ + tokenId, + clientId: client.client_id, + resource: codeData.resource || '', + scopes: codeData.scopes, + ttlMs, + familyId, + }), + ) + : undefined; + + if (!refreshFamily) { + this.oauthStorage.sessionRepository.createWithId( + tokenId, + client.client_id, + codeData.resource || '', + codeData.scopes, + ttlMs, + ); + } const tokens: OAuthTokens = { access_token: accessToken, token_type: 'Bearer', expires_in: Math.floor(ttlMs / 1000), scope: codeData.scopes ? codeData.scopes.join(' ') : '', + ...(refreshFamily ? { refresh_token: refreshFamily.refreshToken } : {}), }; logger.info(`Exchanged authorization code for access token`, { @@ -406,22 +439,63 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { } /** - * Exchanges refresh token for new access token (not implemented) + * Atomically rotates a single-use refresh token and issues a new access token. */ async exchangeRefreshToken( - _client: OAuthClientInformationFull, - _refreshToken: string, - _scopes?: string[], - _resource?: URL, + client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[], + resource?: URL, ): Promise { - throw new Error('Refresh tokens not supported'); + const repository = this.oauthStorage.refreshTokenFamilyRepository; + const family = repository.findByToken(refreshToken); + if (!family || family.clientId !== client.client_id) { + throw new InvalidGrantError('Invalid refresh token'); + } + + const requestedScopes = scopes ?? family.scopeCeiling; + if (requestedScopes.some((scope) => !family.scopeCeiling.includes(scope))) { + throw new InvalidScopeError('Requested scope exceeds the originally consented scope'); + } + + if (resource && resource.toString() !== family.resource) { + throw new InvalidTargetError('Requested resource does not match the original resource'); + } + + const tokenId = randomUUID(); + const ttlMs = this.configManager.get('auth').oauthTokenTtlMs; + const rotation = await repository.consume(refreshToken, client.client_id, tokenId, (familyId) => + this.oauthStorage.sessionRepository.createRefreshFamilyAccessSession({ + tokenId, + clientId: client.client_id, + resource: family.resource, + scopes: requestedScopes, + ttlMs, + familyId, + }), + ); + if (rotation.status === 'replay') { + this.revokeFamilyAccessTokens(rotation.family.accessTokenIds); + throw new InvalidGrantError('Refresh token replay detected'); + } + if (rotation.status !== 'rotated') { + throw new InvalidGrantError('Invalid refresh token'); + } + + return { + access_token: AUTH_CONFIG.SERVER.TOKEN.ID_PREFIX + tokenId, + token_type: 'Bearer', + expires_in: Math.floor(ttlMs / 1000), + scope: requestedScopes.join(' '), + refresh_token: rotation.refreshToken, + }; } /** * Verifies access token and returns auth info with granted scopes */ async verifyAccessToken(token: string): Promise { - logger.debug('Verifying access token', { token }); + logger.debug('Verifying access token'); if (!this.configManager.get('features').auth) { // Auth disabled, return minimal auth info with all available tags as scopes @@ -447,6 +521,13 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { throw new Error('Invalid or expired access token'); } + if (sessionData.refreshFamilyId) { + const family = this.oauthStorage.refreshTokenFamilyRepository.findById(sessionData.refreshFamilyId); + if (!family || family.status !== 'active' || !family.accessTokenIds.includes(tokenId)) { + throw new Error('Invalid or expired access token'); + } + } + return { token, clientId: sessionData.clientId, @@ -460,17 +541,30 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { * Revokes a token */ async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise { - logger.debug('Revoking token', { clientId: client.client_id, request }); + logger.debug('Revoking OAuth token', { clientId: client.client_id }); const token = request.token; + const refreshFamily = this.oauthStorage.refreshTokenFamilyRepository.findByToken(token); + if (refreshFamily) { + const revokedFamily = await this.oauthStorage.refreshTokenFamilyRepository.revokeForClient( + refreshFamily, + client.client_id, + ); + if (revokedFamily) { + this.revokeFamilyAccessTokens(revokedFamily.accessTokenIds); + } + return; + } + // Strip prefix if present const tokenId = token.startsWith(AUTH_CONFIG.SERVER.TOKEN.ID_PREFIX) ? token.slice(AUTH_CONFIG.SERVER.TOKEN.ID_PREFIX.length) : token; const sessionId = AUTH_CONFIG.SERVER.SESSION.ID_PREFIX + tokenId; - const success = this.oauthStorage.sessionRepository.delete(sessionId); + const session = this.oauthStorage.sessionRepository.get(sessionId); + const success = session?.clientId === client.client_id && this.oauthStorage.sessionRepository.delete(sessionId); if (success) { logger.info(`Revoked access token for client ${client.client_id}`, { @@ -479,6 +573,20 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { } } + private revokeFamilyAccessTokens(accessTokenIds: string[]): void { + const failures: unknown[] = []; + for (const accessTokenId of accessTokenIds) { + try { + this.oauthStorage.sessionRepository.delete(AUTH_CONFIG.SERVER.SESSION.ID_PREFIX + accessTokenId); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Failed to revoke access sessions for refresh token family'); + } + } + /** * Graceful shutdown */ @@ -486,3 +594,42 @@ export class SDKOAuthServerProvider implements OAuthServerProvider { this.oauthStorage.shutdown(); } } + +function createConsentPageCsp(client: OAuthClientInformationFull, requestedRedirectUri: string): string { + const callbackOrigin = getValidatedLoopbackCallbackOrigin(client.redirect_uris, requestedRedirectUri); + const formAction = callbackOrigin ? `form-action 'self' ${callbackOrigin};` : "form-action 'self';"; + return `default-src 'none'; ${formAction} ${OAUTH_CONSENT_PAGE_CSP_SUFFIX}`; +} + +function getValidatedLoopbackCallbackOrigin( + registeredRedirectUris: string[], + requestedRedirectUri: string, +): string | null { + let requested: URL; + try { + requested = new URL(requestedRedirectUri); + } catch { + return null; + } + + if (!LOOPBACK_HOSTS.has(requested.hostname)) { + return null; + } + + const matchesRegistered = registeredRedirectUris.some((registeredRedirectUri) => { + try { + const registered = new URL(registeredRedirectUri); + return ( + LOOPBACK_HOSTS.has(registered.hostname) && + requested.protocol === registered.protocol && + requested.hostname === registered.hostname && + requested.pathname === registered.pathname && + requested.search === registered.search + ); + } catch { + return false; + } + }); + + return matchesRegistered ? requested.origin : null; +} diff --git a/src/auth/sessionTypes.ts b/src/auth/sessionTypes.ts index d0865975..3d25c1b8 100644 --- a/src/auth/sessionTypes.ts +++ b/src/auth/sessionTypes.ts @@ -3,6 +3,8 @@ import { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/aut import { ContextNamespace, EnvironmentContext, UserContext } from '@src/types/context.js'; +import { z } from 'zod'; + /** * Base interface for all data that can expire */ @@ -11,11 +13,45 @@ export interface ExpirableData { createdAt: number; } -export interface SessionData extends ExpirableData { - clientId: string; - resource: string; - scopes: string[]; -} +const ExpirableDataShape = { + expires: z.number().finite(), + createdAt: z.number().finite(), +}; + +export const SessionDataSchema = z.object({ + ...ExpirableDataShape, + clientId: z.string().min(1), + resource: z.string(), + scopes: z.array(z.string()), + refreshFamilyId: z.string().optional(), +}); +export type SessionData = z.infer; + +const RefreshTokenDigestSchema = z.string().regex(/^[a-f0-9]{64}$/); + +export const RefreshTokenFamilyDataSchema = z.object({ + ...ExpirableDataShape, + familyId: z.string().min(1), + runtimeScopeId: z.string().min(1), + clientId: z.string().min(1), + scopeCeiling: z.array(z.string()), + resource: z.string(), + currentTokenDigest: RefreshTokenDigestSchema, + consumedTokenDigests: z.array(RefreshTokenDigestSchema), + accessTokenIds: z.array(z.uuid()), + status: z.enum(['active', 'revoked']), + revokedAt: z.number().finite().optional(), +}); +export type RefreshTokenFamilyData = z.infer; + +export const RefreshTokenLookupDataSchema = z.object({ + ...ExpirableDataShape, + familyId: z.string().min(1), + runtimeScopeId: z.string().min(1), + tokenDigest: RefreshTokenDigestSchema, + state: z.enum(['current', 'consumed']), +}); +export type RefreshTokenLookupData = z.infer; export interface ClientData extends ExpirableData, OAuthClientInformationFull {} diff --git a/src/auth/storage/fileStorageService.test.ts b/src/auth/storage/fileStorageService.test.ts index f7618515..6c76e096 100644 --- a/src/auth/storage/fileStorageService.test.ts +++ b/src/auth/storage/fileStorageService.test.ts @@ -38,6 +38,7 @@ describe('FileStorageService', () => { afterEach(() => { service.shutdown(); + vi.restoreAllMocks(); // Clean up temp directory if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -84,6 +85,25 @@ describe('FileStorageService', () => { expect(retrieved).toEqual(testData); }); + it('preserves the previous record when a replacement write fails after truncation', () => { + service.writeData(testPrefix, testId, testData); + const targetPath = service.getFilePath(testPrefix, testId); + const originalWriteFileSync = fs.writeFileSync; + vi.spyOn(fs, 'writeFileSync').mockImplementation((file, data, options) => { + originalWriteFileSync(file, '', options); + throw new Error(`simulated crash while writing ${String(file)}`); + }); + + expect(() => + service.writeDataDurable(testPrefix, testId, { + ...testData, + value: 'replacement value', + }), + ).toThrow('simulated crash'); + + expect(JSON.parse(fs.readFileSync(targetPath, 'utf8'))).toEqual(testData); + }); + it('should return null for non-existent data', () => { const result = service.readData(testPrefix, 'nonexistent'); expect(result).toBeNull(); @@ -110,6 +130,88 @@ describe('FileStorageService', () => { }); }); + describe('Exclusive storage locks', () => { + it('reclaims a lock left by a crashed process', async () => { + const lockPath = path.join(service.getStorageDir(), '.refresh-test.lock'); + fs.mkdirSync(lockPath); + fs.writeFileSync( + path.join(lockPath, 'owner.json'), + JSON.stringify({ operationId: 'abandoned-operation', pid: 2_147_483_647, createdAt: Date.now() }), + ); + + await expect(service.withExclusiveLock('refresh-test', () => 'acquired')).resolves.toBe('acquired'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('recovers from a release rename failure without blocking the next acquisition', async () => { + const originalRenameSync = fs.renameSync; + let failedReleaseRename = false; + vi.spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => { + if (!failedReleaseRename && String(newPath).endsWith('.releasing')) { + failedReleaseRename = true; + throw new Error('simulated lock cleanup failure'); + } + return originalRenameSync(oldPath, newPath); + }); + + await expect(service.withExclusiveLock('refresh-test', () => 'committed')).resolves.toBe('committed'); + expect(failedReleaseRename).toBe(true); + await expect(service.withExclusiveLock('refresh-test', () => 'acquired-again')).resolves.toBe('acquired-again'); + }); + + it('does not overwrite a newer owner created during owner-less lock reclamation', async () => { + const lockPath = path.join(service.getStorageDir(), '.owner-race-test.lock'); + const ownerPath = path.join(lockPath, 'owner.json'); + const replacementOperationId = 'replacement-operation'; + const originalMkdirSync = fs.mkdirSync; + const originalWriteFileSync = fs.writeFileSync; + let injectedReplacementOwner = false; + let exclusiveWriteRejected = false; + let replacementOwnerPreserved = false; + let releaseReplacementOwner = Promise.resolve(); + + vi.spyOn(fs, 'writeFileSync').mockImplementation((file, data, options) => { + if (String(file) === ownerPath && !injectedReplacementOwner) { + injectedReplacementOwner = true; + fs.rmSync(lockPath, { recursive: true, force: true }); + originalMkdirSync(lockPath, { mode: 0o700 }); + originalWriteFileSync( + ownerPath, + JSON.stringify({ operationId: replacementOperationId, pid: process.pid, createdAt: Date.now() }), + { mode: 0o600, flag: 'wx' }, + ); + releaseReplacementOwner = new Promise((resolve) => { + queueMicrotask(() => { + try { + const owner = JSON.parse(fs.readFileSync(ownerPath, 'utf8')) as { operationId?: string }; + replacementOwnerPreserved = owner.operationId === replacementOperationId; + } catch { + replacementOwnerPreserved = false; + } + fs.rmSync(lockPath, { recursive: true, force: true }); + resolve(); + }); + }); + } + + try { + return originalWriteFileSync(file, data, options); + } catch (error) { + if (String(file) === ownerPath) { + exclusiveWriteRejected = true; + } + throw error; + } + }); + + await expect(service.withExclusiveLock('owner-race-test', () => 'acquired')).resolves.toBe('acquired'); + await releaseReplacementOwner; + expect(injectedReplacementOwner).toBe(true); + expect(exclusiveWriteRejected).toBe(true); + expect(replacementOwnerPreserved).toBe(true); + }); + }); + describe('Path Security', () => { it('should prevent path traversal attacks', () => { const maliciousId = '../../../etc/passwd'; diff --git a/src/auth/storage/fileStorageService.ts b/src/auth/storage/fileStorageService.ts index a1828469..80e70d6c 100644 --- a/src/auth/storage/fileStorageService.ts +++ b/src/auth/storage/fileStorageService.ts @@ -1,10 +1,19 @@ import fs from 'fs'; +import { randomUUID } from 'node:crypto'; import path from 'path'; import { ExpirableData } from '@src/auth/sessionTypes.js'; import { AUTH_CONFIG, FILE_PREFIX_MAPPING, getGlobalConfigDir, STORAGE_SUBDIRS } from '@src/constants.js'; import logger from '@src/logger/logger.js'; +import { z, type ZodType } from 'zod'; + +const StorageLockOwnerSchema = z.object({ + operationId: z.string().min(1), + pid: z.number().int().positive(), + createdAt: z.number().finite(), +}); + /** * Generic file storage service with unified cleanup for all expirable data types. * @@ -216,11 +225,17 @@ export class FileStorageService { return false; } + if (filePrefix === AUTH_CONFIG.SERVER.REFRESH_FAMILY.LOOKUP_FILE_PREFIX) { + const { LOOKUP_ID_PREFIX } = AUTH_CONFIG.SERVER.REFRESH_FAMILY; + return id.startsWith(LOOKUP_ID_PREFIX) && /^[a-f0-9]{64}$/.test(id.slice(LOOKUP_ID_PREFIX.length)); + } + // Check for valid server-side prefix const serverPrefixes = [ AUTH_CONFIG.SERVER.SESSION.ID_PREFIX, AUTH_CONFIG.SERVER.AUTH_CODE.ID_PREFIX, AUTH_CONFIG.SERVER.AUTH_REQUEST.ID_PREFIX, + AUTH_CONFIG.SERVER.REFRESH_FAMILY.ID_PREFIX, AUTH_CONFIG.SERVER.STREAMABLE_SESSION.ID_PREFIX, ]; @@ -280,11 +295,43 @@ export class FileStorageService { } } + /** + * Atomically replaces a record and flushes it before returning. + */ + writeDataDurable(filePrefix: string, id: string, data: T): void { + let temporaryPath: string | undefined; + try { + const filePath = this.getFilePath(filePrefix, id); + temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + const fileDescriptor = fs.openSync(temporaryPath, 'wx', 0o600); + try { + fs.writeFileSync(fileDescriptor, JSON.stringify(data, null, 2)); + fs.fsyncSync(fileDescriptor); + } finally { + fs.closeSync(fileDescriptor); + } + fs.renameSync(temporaryPath, filePath); + temporaryPath = undefined; + this.flushStorageDirectory(); + logger.debug(`Wrote data to ${filePath}`); + } catch (error) { + if (temporaryPath) { + try { + fs.unlinkSync(temporaryPath); + } catch { + // A later startup cleanup removes abandoned temporary files. + } + } + logger.error(`Failed to write data for ${id}: ${error}`); + throw error; + } + } + /** * Reads data from a file with the specified prefix and ID * Returns null if file doesn't exist or data is expired */ - readData(filePrefix: string, id: string): T | null { + readData(filePrefix: string, id: string, schema?: ZodType): T | null { if (!this.isValidId(id, filePrefix)) { logger.warn(`Rejected readData with invalid ID: ${id}`); return null; @@ -297,7 +344,8 @@ export class FileStorageService { } const data = fs.readFileSync(filePath, 'utf8'); - const parsedData: T = JSON.parse(data) as T; + const parsed: unknown = JSON.parse(data); + const parsedData = schema ? schema.parse(parsed) : (parsed as T); // Check if data is expired if (parsedData.expires < Date.now()) { @@ -331,7 +379,36 @@ export class FileStorageService { return false; } catch (error) { logger.error(`Failed to delete data for ${id}: ${error}`); - return false; + throw error; + } + } + + /** + * Runs a storage transition under an inter-process lock. + * + * Lock ownership is recorded so a process that dies while holding the lock + * cannot block the Runtime Scope permanently. + */ + async withExclusiveLock(lockName: string, operation: () => Promise | T): Promise { + if (!/^[a-z0-9-]+$/.test(lockName)) { + throw new Error(`Invalid storage lock name: ${lockName}`); + } + + const lockPath = path.join(this.storageDir, `.${lockName}.lock`); + const operationId = randomUUID(); + const deadline = Date.now() + 10_000; + + while (!this.tryAcquireLock(lockPath, operationId)) { + if (Date.now() >= deadline) { + throw new Error(`Timed out acquiring storage lock: ${lockName}`); + } + await new Promise((resolve) => setTimeout(resolve, 5 + Math.floor(Math.random() * 10))); + } + + try { + return await operation(); + } finally { + this.releaseLock(lockPath, operationId); } } @@ -357,6 +434,20 @@ export class FileStorageService { let cleanedCount = 0; for (const file of files) { + if (file.includes('.json.') && file.endsWith('.tmp')) { + const temporaryPath = path.join(this.storageDir, file); + try { + const ageMs = Date.now() - fs.statSync(temporaryPath).mtimeMs; + if (ageMs >= 60_000) { + fs.unlinkSync(temporaryPath); + cleanedCount++; + } + } catch (error) { + logger.warn(`Failed to clean temporary file ${file}: ${error}`); + } + continue; + } + if (file.endsWith(AUTH_CONFIG.SERVER.STORAGE.FILE_EXTENSION)) { const filePath = path.join(this.storageDir, file); try { @@ -439,4 +530,178 @@ export class FileStorageService { logger.info('FileStorageService cleanup interval stopped'); } } + + private flushStorageDirectory(): void { + try { + const directoryDescriptor = fs.openSync(this.storageDir, 'r'); + try { + fs.fsyncSync(directoryDescriptor); + } finally { + fs.closeSync(directoryDescriptor); + } + } catch (error) { + if ( + process.platform === 'win32' && + error instanceof Error && + 'code' in error && + ['EACCES', 'EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM'].includes(String(error.code)) + ) { + return; + } + throw error; + } + } + + private tryAcquireLock(lockPath: string, operationId: string): boolean { + try { + fs.mkdirSync(lockPath, { mode: 0o700 }); + } catch (error) { + if (!isFileExistsError(error)) { + throw error; + } + this.reclaimAbandonedLock(lockPath); + return false; + } + + try { + fs.writeFileSync( + path.join(lockPath, 'owner.json'), + JSON.stringify({ operationId, pid: process.pid, createdAt: Date.now() }), + { mode: 0o600, flag: 'wx' }, + ); + this.flushStorageDirectory(); + return true; + } catch (error) { + const owner = this.readLockOwner(lockPath); + if (owner?.operationId === operationId) { + try { + removeLockDirectory(lockPath); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], `Failed to initialize storage lock: ${lockPath}`); + } + } + if (isFileExistsError(error)) { + return false; + } + throw error; + } + } + + private reclaimAbandonedLock(lockPath: string): void { + const owner = this.readLockOwner(lockPath); + if (owner && isProcessAlive(owner.pid)) { + return; + } + + if (!owner) { + try { + if (Date.now() - fs.statSync(lockPath).mtimeMs < 1_000) { + return; + } + } catch { + return; + } + } + + const observedOperationId = owner?.operationId; + const tombstonePath = `${lockPath}.${randomUUID()}.stale`; + try { + fs.renameSync(lockPath, tombstonePath); + } catch { + return; + } + + const movedOwner = this.readLockOwner(tombstonePath); + if (observedOperationId && movedOwner?.operationId !== observedOperationId) { + try { + fs.renameSync(tombstonePath, lockPath); + } catch { + // Another contender will reconcile the surviving generation. + } + return; + } + + removeLockDirectory(tombstonePath); + } + + private releaseLock(lockPath: string, operationId: string): void { + const owner = this.readLockOwner(lockPath); + if (owner?.operationId !== operationId) { + logger.error(`Storage lock ownership changed before release: ${lockPath}`); + return; + } + + const tombstonePath = `${lockPath}.${operationId}.releasing`; + try { + fs.renameSync(lockPath, tombstonePath); + } catch (renameError) { + const currentOwner = this.readLockOwner(lockPath); + if (!currentOwner && !fs.existsSync(lockPath)) { + logger.warn(`Storage lock disappeared during release: ${lockPath}`); + return; + } + if (currentOwner?.operationId !== operationId) { + logger.error(`Storage lock ownership changed during release: ${lockPath}`); + throw renameError; + } + + try { + removeLockDirectory(lockPath); + } catch (cleanupError) { + throw new AggregateError( + [renameError, cleanupError], + `Failed to release storage lock after rename failure: ${lockPath}`, + ); + } + + try { + this.flushStorageDirectory(); + } catch (flushError) { + logger.error(`Failed to flush storage directory after lock release ${lockPath}: ${flushError}`); + } + logger.warn(`Released storage lock without rename after rename failure: ${lockPath}`); + return; + } + + try { + removeLockDirectory(tombstonePath); + this.flushStorageDirectory(); + } catch (error) { + logger.error(`Failed to release storage lock ${lockPath}: ${error}`); + } + } + + private readLockOwner(lockPath: string): { operationId: string; pid: number } | null { + try { + const value: unknown = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8')); + const result = StorageLockOwnerSchema.safeParse(value); + return result.success ? { operationId: result.data.operationId, pid: result.data.pid } : null; + } catch { + return null; + } + } +} + +function isFileExistsError(error: unknown): error is Error & { code: string } { + return error instanceof Error && 'code' in error && error.code === 'EEXIST'; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'EPERM'; + } +} + +function removeLockDirectory(lockPath: string): void { + try { + fs.unlinkSync(path.join(lockPath, 'owner.json')); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + throw error; + } + } + fs.rmdirSync(lockPath); } diff --git a/src/auth/storage/oauthStorageService.ts b/src/auth/storage/oauthStorageService.ts index 5085eb2f..59e4f750 100644 --- a/src/auth/storage/oauthStorageService.ts +++ b/src/auth/storage/oauthStorageService.ts @@ -1,4 +1,5 @@ import { AUTH_CONFIG } from '@src/constants.js'; +import { RuntimeIdentityService } from '@src/core/runtime/runtimeIdentityService.js'; import logger from '@src/logger/logger.js'; import { auditScopeOperation } from '@src/utils/validation/scopeValidation.js'; @@ -6,6 +7,7 @@ import { AuthCodeRepository } from './authCodeRepository.js'; import { AuthRequestRepository } from './authRequestRepository.js'; import { ClientDataRepository } from './clientDataRepository.js'; import { FileStorageService } from './fileStorageService.js'; +import { RefreshTokenFamilyRepository } from './refreshTokenFamilyRepository.js'; import { SessionRepository } from './sessionRepository.js'; /** @@ -22,13 +24,15 @@ export class OAuthStorageService { private authCodes: AuthCodeRepository; private authRequests: AuthRequestRepository; private clientData: ClientDataRepository; + private refreshTokenFamilies: RefreshTokenFamilyRepository; - constructor(storageDir?: string) { + constructor(storageDir?: string, runtimeScopeId = new RuntimeIdentityService({ storageDir }).getRuntimeScopeId()) { this.storage = new FileStorageService(storageDir, AUTH_CONFIG.SERVER.SESSION.SUBDIR); this.sessions = new SessionRepository(this.storage); this.authCodes = new AuthCodeRepository(this.storage); this.authRequests = new AuthRequestRepository(this.storage); this.clientData = new ClientDataRepository(this.storage); + this.refreshTokenFamilies = new RefreshTokenFamilyRepository(this.storage, runtimeScopeId); } /** @@ -155,6 +159,10 @@ export class OAuthStorageService { return this.clientData; } + get refreshTokenFamilyRepository(): RefreshTokenFamilyRepository { + return this.refreshTokenFamilies; + } + /** * Gets the storage directory path */ diff --git a/src/auth/storage/refreshTokenFamilyRepository.ts b/src/auth/storage/refreshTokenFamilyRepository.ts new file mode 100644 index 00000000..adbf9b8c --- /dev/null +++ b/src/auth/storage/refreshTokenFamilyRepository.ts @@ -0,0 +1,245 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; + +import { + RefreshTokenFamilyData, + RefreshTokenFamilyDataSchema, + RefreshTokenLookupData, + RefreshTokenLookupDataSchema, + SessionData, + SessionDataSchema, +} from '@src/auth/sessionTypes.js'; +import { AUTH_CONFIG } from '@src/constants.js'; + +import { FileStorageService } from './fileStorageService.js'; + +export type RefreshTokenConsumptionResult = + | { status: 'rotated'; family: RefreshTokenFamilyData; refreshToken: string } + | { status: 'replay'; family: RefreshTokenFamilyData } + | { status: 'invalid' } + | { status: 'client_mismatch' }; + +const REFRESH_FAMILY_LOCK = 'refresh-token-families'; + +export class RefreshTokenFamilyRepository { + constructor( + private readonly storage: FileStorageService, + private readonly runtimeScopeId: string, + ) {} + + async create( + clientId: string, + scopeCeiling: string[], + resource: string, + accessTokenId: string, + persistAccessSession: (familyId: string) => void, + ): Promise<{ + family: RefreshTokenFamilyData; + refreshToken: string; + }> { + return this.storage.withExclusiveLock(REFRESH_FAMILY_LOCK, () => { + const familyId = AUTH_CONFIG.SERVER.REFRESH_FAMILY.ID_PREFIX + randomUUID(); + const refreshToken = createRefreshToken(); + const now = Date.now(); + const family: RefreshTokenFamilyData = { + familyId, + runtimeScopeId: this.runtimeScopeId, + clientId, + scopeCeiling: [...scopeCeiling], + resource, + currentTokenDigest: digestRefreshToken(refreshToken), + consumedTokenDigests: [], + accessTokenIds: [accessTokenId], + status: 'active', + createdAt: now, + expires: now + AUTH_CONFIG.SERVER.REFRESH_FAMILY.TTL_MS, + }; + + persistAccessSession(familyId); + this.saveLookup(family, family.currentTokenDigest, 'current'); + this.save(family); + return { family, refreshToken }; + }); + } + + findByToken(refreshToken: string): RefreshTokenFamilyData | null { + return this.locateByDigest(digestRefreshToken(refreshToken))?.family ?? null; + } + + findById(familyId: string): RefreshTokenFamilyData | null { + const family = this.storage.readData( + AUTH_CONFIG.SERVER.REFRESH_FAMILY.FILE_PREFIX, + familyId, + RefreshTokenFamilyDataSchema, + ); + return family?.runtimeScopeId === this.runtimeScopeId ? family : null; + } + + async consume( + refreshToken: string, + clientId: string, + accessTokenId: string, + persistAccessSession: (familyId: string) => void, + ): Promise { + const digest = digestRefreshToken(refreshToken); + return this.storage.withExclusiveLock(REFRESH_FAMILY_LOCK, () => { + const located = this.locateByDigest(digest); + if (!located || located.family.runtimeScopeId !== this.runtimeScopeId) { + return { status: 'invalid' }; + } + + const { family } = located; + if (family.clientId !== clientId) { + return { status: 'client_mismatch' }; + } + + const tokenState = safeDigestEqual(family.currentTokenDigest, digest) + ? 'current' + : family.consumedTokenDigests.some((consumed) => safeDigestEqual(consumed, digest)) || + located.lookup?.state === 'consumed' + ? 'consumed' + : 'unknown'; + + if (tokenState === 'consumed') { + const revokedFamily = this.revoke(family); + return { status: 'replay', family: revokedFamily }; + } + + if (tokenState !== 'current' || family.status !== 'active') { + return { status: 'invalid' }; + } + + const nextRefreshToken = createRefreshToken(); + const nextDigest = digestRefreshToken(nextRefreshToken); + const activeAccessTokenIds = family.accessTokenIds.filter((tokenId) => this.isAccessSessionActive(tokenId)); + const rotatedFamily: RefreshTokenFamilyData = { + ...family, + currentTokenDigest: nextDigest, + // Older consumed digests remain replay-detectable through lookup records. + consumedTokenDigests: [family.currentTokenDigest], + accessTokenIds: [...activeAccessTokenIds, accessTokenId], + }; + + persistAccessSession(family.familyId); + for (const historicDigest of family.consumedTokenDigests) { + this.saveLookup(family, historicDigest, 'consumed'); + } + this.saveLookup(family, family.currentTokenDigest, 'consumed'); + this.saveLookup(family, nextDigest, 'current'); + this.save(rotatedFamily); + return { status: 'rotated', family: rotatedFamily, refreshToken: nextRefreshToken }; + }); + } + + async revokeForClient(family: RefreshTokenFamilyData, clientId: string): Promise { + return this.storage.withExclusiveLock(REFRESH_FAMILY_LOCK, () => { + const currentFamily = this.findById(family.familyId); + if (!currentFamily || currentFamily.clientId !== clientId) { + return null; + } + return this.revoke(currentFamily); + }); + } + + private revoke(family: RefreshTokenFamilyData): RefreshTokenFamilyData { + if (family.status === 'revoked') { + return family; + } + const revokedFamily: RefreshTokenFamilyData = { + ...family, + status: 'revoked', + revokedAt: Date.now(), + }; + this.save(revokedFamily); + return revokedFamily; + } + + private locateByDigest(digest: string): { family: RefreshTokenFamilyData; lookup?: RefreshTokenLookupData } | null { + const lookup = this.readLookup(digest); + if (lookup?.runtimeScopeId === this.runtimeScopeId) { + const family = this.findById(lookup.familyId); + if (family) { + return { family, lookup }; + } + } + + const family = this.list().find((candidate) => tokenDigestMatches(candidate, digest)); + return family ? { family } : null; + } + + private readLookup(digest: string): RefreshTokenLookupData | null { + return this.storage.readData( + AUTH_CONFIG.SERVER.REFRESH_FAMILY.LOOKUP_FILE_PREFIX, + lookupId(digest), + RefreshTokenLookupDataSchema, + ); + } + + private saveLookup( + family: RefreshTokenFamilyData, + tokenDigest: string, + state: RefreshTokenLookupData['state'], + ): void { + this.storage.writeDataDurable(AUTH_CONFIG.SERVER.REFRESH_FAMILY.LOOKUP_FILE_PREFIX, lookupId(tokenDigest), { + familyId: family.familyId, + runtimeScopeId: family.runtimeScopeId, + tokenDigest, + state, + createdAt: family.createdAt, + expires: family.expires, + }); + } + + private isAccessSessionActive(accessTokenId: string): boolean { + return ( + this.storage.readData( + AUTH_CONFIG.SERVER.SESSION.FILE_PREFIX, + AUTH_CONFIG.SERVER.SESSION.ID_PREFIX + accessTokenId, + SessionDataSchema, + ) !== null + ); + } + + private list(): RefreshTokenFamilyData[] { + const { FILE_PREFIX, ID_PREFIX } = AUTH_CONFIG.SERVER.REFRESH_FAMILY; + const extension = AUTH_CONFIG.SERVER.STORAGE.FILE_EXTENSION; + return this.storage + .listFiles(FILE_PREFIX) + .map((fileName) => fileName.slice(FILE_PREFIX.length, -extension.length)) + .filter((familyId) => familyId.startsWith(ID_PREFIX)) + .map((familyId) => + this.storage.readData(FILE_PREFIX, familyId, RefreshTokenFamilyDataSchema), + ) + .filter( + (family): family is RefreshTokenFamilyData => family !== null && family.runtimeScopeId === this.runtimeScopeId, + ); + } + + private save(family: RefreshTokenFamilyData): void { + this.storage.writeDataDurable(AUTH_CONFIG.SERVER.REFRESH_FAMILY.FILE_PREFIX, family.familyId, family); + } +} + +export function digestRefreshToken(refreshToken: string): string { + return createHash('sha256').update(refreshToken).digest('hex'); +} + +function createRefreshToken(): string { + return AUTH_CONFIG.SERVER.REFRESH_FAMILY.TOKEN_PREFIX + randomBytes(32).toString('base64url'); +} + +function lookupId(digest: string): string { + return AUTH_CONFIG.SERVER.REFRESH_FAMILY.LOOKUP_ID_PREFIX + digest; +} + +function tokenDigestMatches(family: RefreshTokenFamilyData, digest: string): boolean { + return ( + safeDigestEqual(family.currentTokenDigest, digest) || + family.consumedTokenDigests.some((consumed) => safeDigestEqual(consumed, digest)) + ); +} + +function safeDigestEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, 'hex'); + const rightBuffer = Buffer.from(right, 'hex'); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} diff --git a/src/auth/storage/sessionRepository.ts b/src/auth/storage/sessionRepository.ts index 0a659f8e..21d8a3cd 100644 --- a/src/auth/storage/sessionRepository.ts +++ b/src/auth/storage/sessionRepository.ts @@ -1,11 +1,20 @@ import { randomUUID } from 'node:crypto'; -import { SessionData } from '@src/auth/sessionTypes.js'; +import { SessionData, SessionDataSchema } from '@src/auth/sessionTypes.js'; import { AUTH_CONFIG } from '@src/constants.js'; import logger from '@src/logger/logger.js'; import { FileStorageService } from './fileStorageService.js'; +export interface RefreshFamilyAccessSessionInput { + tokenId: string; + clientId: string; + resource: string; + scopes: string[]; + ttlMs: number; + familyId: string; +} + /** * Repository for session operations * @@ -36,18 +45,61 @@ export class SessionRepository { /** * Creates a session with a specific token ID (for access tokens) */ - createWithId(tokenId: string, clientId: string, resource: string, scopes: string[], ttlMs: number): string { - const sessionId = AUTH_CONFIG.SERVER.SESSION.ID_PREFIX + tokenId; + createWithId( + tokenId: string, + clientId: string, + resource: string, + scopes: string[], + ttlMs: number, + refreshFamilyId?: string, + ): string { + return this.persistWithId({ tokenId, clientId, resource, scopes, ttlMs, refreshFamilyId }, false); + } + + /** + * Persists a refresh-family access session before the family commit point. + */ + createRefreshFamilyAccessSession(input: RefreshFamilyAccessSessionInput): string { + return this.persistWithId( + { + tokenId: input.tokenId, + clientId: input.clientId, + resource: input.resource, + scopes: input.scopes, + ttlMs: input.ttlMs, + refreshFamilyId: input.familyId, + }, + true, + ); + } + + private persistWithId( + input: { + tokenId: string; + clientId: string; + resource: string; + scopes: string[]; + ttlMs: number; + refreshFamilyId?: string; + }, + durable: boolean, + ): string { + const sessionId = AUTH_CONFIG.SERVER.SESSION.ID_PREFIX + input.tokenId; const sessionData: SessionData = { - clientId, - resource, - scopes, - expires: Date.now() + ttlMs, + clientId: input.clientId, + resource: input.resource, + scopes: input.scopes, + refreshFamilyId: input.refreshFamilyId, + expires: Date.now() + input.ttlMs, createdAt: Date.now(), }; - this.storage.writeData(AUTH_CONFIG.SERVER.SESSION.FILE_PREFIX, sessionId, sessionData); - logger.info(`Created session with ID: ${sessionId} for client: ${clientId}`); + if (durable) { + this.storage.writeDataDurable(AUTH_CONFIG.SERVER.SESSION.FILE_PREFIX, sessionId, sessionData); + } else { + this.storage.writeData(AUTH_CONFIG.SERVER.SESSION.FILE_PREFIX, sessionId, sessionData); + } + logger.info(`Created session with ID: ${sessionId} for client: ${input.clientId}`); return sessionId; } @@ -55,7 +107,7 @@ export class SessionRepository { * Retrieves a session by ID */ get(sessionId: string): SessionData | null { - return this.storage.readData(AUTH_CONFIG.SERVER.SESSION.FILE_PREFIX, sessionId); + return this.storage.readData(AUTH_CONFIG.SERVER.SESSION.FILE_PREFIX, sessionId, SessionDataSchema); } /** diff --git a/src/constants/auth.ts b/src/constants/auth.ts index 1d2b53ea..d6b21bba 100644 --- a/src/constants/auth.ts +++ b/src/constants/auth.ts @@ -44,6 +44,17 @@ export const AUTH_CONFIG = { ID_PREFIX: 'tk-', }, + // OAuth refresh token families + REFRESH_FAMILY: { + TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days, fixed at family creation + ID_PREFIX: 'rf-', + FILE_PREFIX: 'refresh_family_', + LOOKUP_ID_PREFIX: 'rtl-', + LOOKUP_FILE_PREFIX: 'refresh_lookup_', + TOKEN_PREFIX: 'rt-', + SUBDIR: 'server', + }, + // Streamable HTTP sessions STREAMABLE_SESSION: { TTL_MS: 24 * 60 * 60 * 1000, // 24 hours @@ -101,7 +112,7 @@ export const STORAGE_SUBDIRS = { // File prefix mapping for migration logic export const FILE_PREFIX_MAPPING = { - SERVER: ['session_', 'auth_code_', 'auth_request_'], + SERVER: ['session_', 'auth_code_', 'auth_request_', 'refresh_family_', 'refresh_lookup_'], CLIENT: ['oauth_', 'cli_', 'tok_', 'ver_', 'sta_'], TRANSPORT: ['streamable_session_'], } as const; diff --git a/src/core/runtime/runtimeIdentityService.ts b/src/core/runtime/runtimeIdentityService.ts index da978629..4b167fb3 100644 --- a/src/core/runtime/runtimeIdentityService.ts +++ b/src/core/runtime/runtimeIdentityService.ts @@ -42,7 +42,7 @@ export class RuntimeIdentityService { } getRuntimeIdentity(input: RuntimeIdentityInput): RuntimeIdentity { - const runtimeScopeId = this.getOrCreateRuntimeScopeId(); + const runtimeScopeId = this.getRuntimeScopeId(); const identity: RuntimeIdentity = { identityProtocolVersion: '1', runtimeScopeId, @@ -57,6 +57,10 @@ export class RuntimeIdentityService { return identity; } + getRuntimeScopeId(): string { + return this.getOrCreateRuntimeScopeId(); + } + private getOrCreateRuntimeScopeId(): string { const existing = this.readPersistedIdentity(); if (existing?.runtimeScopeId) { diff --git a/src/transport/http/server.ts b/src/transport/http/server.ts index 6998fef1..30905fe6 100644 --- a/src/transport/http/server.ts +++ b/src/transport/http/server.ts @@ -266,7 +266,10 @@ export class ExpressServer { // Initialize OAuth provider with custom session storage path if configured const sessionStoragePath = this.configManager.get('auth').sessionStoragePath; - this.oauthProvider = new SDKOAuthServerProvider(sessionStoragePath); + this.oauthProvider = new SDKOAuthServerProvider( + sessionStoragePath, + this.runtimeIdentityService.getRuntimeScopeId(), + ); // Initialize streamable session repository with 'transport' subdirectory const fileStorageService = new FileStorageService(sessionStoragePath, STORAGE_SUBDIRS.TRANSPORT); diff --git a/test/e2e/fixtures/oauth-refresh-worker.mjs b/test/e2e/fixtures/oauth-refresh-worker.mjs new file mode 100644 index 00000000..bdf6d059 --- /dev/null +++ b/test/e2e/fixtures/oauth-refresh-worker.mjs @@ -0,0 +1,40 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; + +import { FileStorageService } from '../../../build/auth/storage/fileStorageService.js'; +import { RefreshTokenFamilyRepository } from '../../../build/auth/storage/refreshTokenFamilyRepository.js'; +import { SessionRepository } from '../../../build/auth/storage/sessionRepository.js'; +import { AUTH_CONFIG } from '../../../build/constants.js'; + +const [storageDir, runtimeScopeId, refreshToken, clientId, startedPath, markerPath, releasePath] = + process.argv.slice(2); +const storage = new FileStorageService(storageDir, AUTH_CONFIG.SERVER.SESSION.SUBDIR); +const sessions = new SessionRepository(storage); +const repository = new RefreshTokenFamilyRepository(storage, runtimeScopeId); +const accessTokenId = randomUUID(); + +try { + fs.writeFileSync(startedPath, String(process.pid)); + const result = await repository.consume(refreshToken, clientId, accessTokenId, (familyId) => { + fs.writeFileSync(markerPath, String(process.pid)); + if (releasePath) { + const deadline = Date.now() + 10_000; + const sleepHandle = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(releasePath) && Date.now() < deadline) { + Atomics.wait(sleepHandle, 0, 0, 10); + } + if (!fs.existsSync(releasePath)) { + throw new Error('Timed out waiting for transition release'); + } + } + sessions.createWithId(accessTokenId, clientId, 'https://resource.example/mcp', ['tag:alpha'], 60_000, familyId); + }); + process.stdout.write(`RESULT ${JSON.stringify({ status: result.status })}\n`); +} catch (error) { + process.stdout.write( + `RESULT ${JSON.stringify({ status: 'error', message: error instanceof Error ? error.message : String(error) })}\n`, + ); + process.exitCode = 1; +} finally { + storage.shutdown(); +} diff --git a/test/e2e/oauth-loopback-consent.e2e.test.ts b/test/e2e/oauth-loopback-consent.e2e.test.ts new file mode 100644 index 00000000..b3a23bfd --- /dev/null +++ b/test/e2e/oauth-loopback-consent.e2e.test.ts @@ -0,0 +1,268 @@ +import { createHash, randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; + +import { SDKOAuthServerProvider } from '@src/auth/sdkOAuthServerProvider.js'; +import { McpConfigManager } from '@src/config/mcpConfigManager.js'; +import { AgentConfigManager } from '@src/core/server/agentConfig.js'; +import { createOAuthRoutes } from '@src/transport/http/routes/oauthRoutes.js'; + +import express from 'express'; +import { type Browser, chromium } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +let resolveCallback: ((url: URL) => void) | undefined; + +describe('OAuth loopback consent browser flow', () => { + let authServer: Server; + let callbackServer: Server; + let authBaseUrl: string; + let callbackUrl: string; + let browser: Browser; + let provider: SDKOAuthServerProvider; + let storageDir: string; + let originalAuthEnabled: boolean; + + beforeAll(async () => { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), '1mcp-oauth-browser-')); + originalAuthEnabled = AgentConfigManager.getInstance().get('features').auth; + AgentConfigManager.getInstance().get('features').auth = true; + vi.spyOn(McpConfigManager, 'getInstance').mockReturnValue({ + getAvailableTags: () => ['test', 'other'], + } as unknown as McpConfigManager); + + callbackServer = createServer((request, response) => { + resolveCallback?.(new URL(request.url ?? '/', callbackUrl)); + resolveCallback = undefined; + response.writeHead(200, { 'Content-Type': 'text/plain' }); + response.end('Authorization complete'); + }); + callbackUrl = `${await listenOnLoopback(callbackServer)}/callback`; + + const app = express(); + app.use(express.urlencoded({ extended: false })); + authServer = createServer(app); + authBaseUrl = await listenOnLoopback(authServer); + provider = new SDKOAuthServerProvider(storageDir, 'browser-runtime-scope'); + const issuerUrl = new URL(`${authBaseUrl}/`); + app.use( + mcpAuthRouter({ + provider, + issuerUrl, + baseUrl: issuerUrl, + scopesSupported: ['tag:test', 'tag:other'], + authorizationOptions: { rateLimit: false }, + tokenOptions: { rateLimit: false }, + revocationOptions: { rateLimit: false }, + clientRegistrationOptions: { rateLimit: false }, + }), + ); + app.use('/oauth', createOAuthRoutes(provider)); + browser = await chromium.launch({ headless: true }); + }, 30_000); + + afterAll(async () => { + await browser?.close(); + provider?.shutdown(); + await closeServer(authServer); + await closeServer(callbackServer); + fs.rmSync(storageDir, { recursive: true, force: true }); + AgentConfigManager.getInstance().get('features').auth = originalAuthEnabled; + vi.restoreAllMocks(); + }); + + it('completes DCR, cross-port loopback consent, PKCE exchange, and refresh rotation', async () => { + const registrationResponse = await fetch(`${authBaseUrl}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Loopback Browser Client', + redirect_uris: ['http://127.0.0.1:1/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }), + }); + expect(registrationResponse.status, await registrationResponse.clone().text()).toBe(201); + const client = (await registrationResponse.json()) as { client_id: string }; + + const codeVerifier = randomBytes(48).toString('base64url'); + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url'); + const resource = `${authBaseUrl}/mcp`; + const authorizationUrl = new URL(`${authBaseUrl}/authorize`); + authorizationUrl.search = new URLSearchParams({ + response_type: 'code', + client_id: client.client_id, + redirect_uri: callbackUrl, + scope: 'tag:test tag:other', + state: 'browser-state', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + resource, + }).toString(); + + const context = await browser.newContext(); + const page = await context.newPage(); + try { + const consentResponse = await page.goto(authorizationUrl.toString()); + expect(consentResponse?.status()).toBe(200); + expect(consentResponse?.headers()['content-security-policy']).toContain( + `form-action 'self' ${new URL(callbackUrl).origin}`, + ); + await page.getByText('renew this access for up to 30 days').waitFor({ state: 'visible' }); + + const callbackReceived = waitForCallback(); + await page.getByRole('button', { name: 'Approve' }).click(); + const callback = await callbackReceived; + expect(callback.searchParams.get('state')).toBe('browser-state'); + const authorizationCode = callback.searchParams.get('code'); + expect(authorizationCode).toMatch(/^code-/); + + const tokenResponse = await postForm(`${authBaseUrl}/token`, { + grant_type: 'authorization_code', + client_id: client.client_id, + code: authorizationCode!, + redirect_uri: callbackUrl, + code_verifier: codeVerifier, + resource, + }); + expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200); + const tokens = (await tokenResponse.json()) as { access_token: string; refresh_token: string; scope: string }; + expect(tokens.access_token).toMatch(/^tk-/); + expect(tokens.refresh_token).toMatch(/^rt-[A-Za-z0-9_-]{43}$/); + + const expandedScopeResponse = await postForm(`${authBaseUrl}/token`, { + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: tokens.refresh_token, + scope: 'tag:test tag:outside', + resource, + }); + expect(expandedScopeResponse.status).toBe(400); + await expect(expandedScopeResponse.json()).resolves.toMatchObject({ error: 'invalid_scope' }); + + const wrongResourceResponse = await postForm(`${authBaseUrl}/token`, { + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: tokens.refresh_token, + resource: `${authBaseUrl}/other`, + }); + expect(wrongResourceResponse.status).toBe(400); + await expect(wrongResourceResponse.json()).resolves.toMatchObject({ error: 'invalid_target' }); + + const refreshResponse = await postForm(`${authBaseUrl}/token`, { + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: tokens.refresh_token, + scope: 'tag:test', + resource, + }); + expect(refreshResponse.status, await refreshResponse.clone().text()).toBe(200); + const rotated = (await refreshResponse.json()) as { access_token: string; refresh_token: string; scope: string }; + expect(rotated.access_token).toMatch(/^tk-/); + expect(rotated.refresh_token).toMatch(/^rt-/); + expect(rotated.refresh_token).not.toBe(tokens.refresh_token); + expect(rotated.scope).toBe('tag:test'); + + const replayResponse = await postForm(`${authBaseUrl}/token`, { + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: tokens.refresh_token, + resource, + }); + expect(replayResponse.status).toBe(400); + await expect(replayResponse.json()).resolves.toMatchObject({ error: 'invalid_grant' }); + await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow('Invalid or expired access token'); + await expect(provider.verifyAccessToken(rotated.access_token)).rejects.toThrow('Invalid or expired access token'); + + const secondVerifier = randomBytes(48).toString('base64url'); + const secondChallenge = createHash('sha256').update(secondVerifier).digest('base64url'); + const secondAuthorizationUrl = new URL(`${authBaseUrl}/authorize`); + secondAuthorizationUrl.search = new URLSearchParams({ + response_type: 'code', + client_id: client.client_id, + redirect_uri: callbackUrl, + scope: 'tag:test', + state: 'revocation-state', + code_challenge: secondChallenge, + code_challenge_method: 'S256', + resource, + }).toString(); + + await page.goto(secondAuthorizationUrl.toString()); + const secondCallbackReceived = waitForCallback(); + await page.getByRole('button', { name: 'Approve' }).click(); + const secondCallback = await secondCallbackReceived; + const secondTokenResponse = await postForm(`${authBaseUrl}/token`, { + grant_type: 'authorization_code', + client_id: client.client_id, + code: secondCallback.searchParams.get('code')!, + redirect_uri: callbackUrl, + code_verifier: secondVerifier, + resource, + }); + expect(secondTokenResponse.status).toBe(200); + const secondTokens = (await secondTokenResponse.json()) as { access_token: string; refresh_token: string }; + + const revocationResponse = await postForm(`${authBaseUrl}/revoke`, { + token: secondTokens.refresh_token, + client_id: client.client_id, + }); + expect(revocationResponse.status, await revocationResponse.clone().text()).toBe(200); + await expect(provider.verifyAccessToken(secondTokens.access_token)).rejects.toThrow( + 'Invalid or expired access token', + ); + const revokedRefreshResponse = await postForm(`${authBaseUrl}/token`, { + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: secondTokens.refresh_token, + resource, + }); + expect(revokedRefreshResponse.status).toBe(400); + await expect(revokedRefreshResponse.json()).resolves.toMatchObject({ error: 'invalid_grant' }); + } finally { + await context.close(); + } + }, 30_000); +}); + +function waitForCallback(timeoutMs = 10_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolveCallback = undefined; + reject(new Error('Timed out waiting for the loopback callback')); + }, timeoutMs); + resolveCallback = (url) => { + clearTimeout(timer); + resolve(url); + }; + }); +} + +async function listenOnLoopback(server: Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Test server did not bind to a loopback port'); + } + return `http://127.0.0.1:${address.port}`; +} + +async function closeServer(server: Server | undefined): Promise { + if (!server?.listening) { + return; + } + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +} + +function postForm(url: string, values: Record): Promise { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(values), + }); +} diff --git a/test/e2e/oauth-refresh-concurrency.e2e.test.ts b/test/e2e/oauth-refresh-concurrency.e2e.test.ts new file mode 100644 index 00000000..e65bac4c --- /dev/null +++ b/test/e2e/oauth-refresh-concurrency.e2e.test.ts @@ -0,0 +1,148 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; + +import { SDKOAuthServerProvider } from '@src/auth/sdkOAuthServerProvider.js'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const CLIENT: OAuthClientInformationFull = { + client_id: 'cross-process-client', + redirect_uris: ['http://127.0.0.1:3000/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', +}; +const RESOURCE = 'https://resource.example/mcp'; +const RUNTIME_SCOPE_ID = 'cross-process-runtime-scope'; +const WORKER_PATH = fileURLToPath(new URL('./fixtures/oauth-refresh-worker.mjs', import.meta.url)); + +describe('refresh token family cross-process persistence', () => { + let tempDir: string | undefined; + const children = new Set>(); + + afterEach(async () => { + const activeChildren = [...children].filter((child) => child.exitCode === null && child.signalCode === null); + for (const child of activeChildren) { + child.kill('SIGKILL'); + } + await Promise.all(activeChildren.map(waitForExit)); + children.clear(); + + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('allows exactly one process to commit a refresh-token rotation', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), '1mcp-refresh-process-')); + const provider = new SDKOAuthServerProvider(tempDir, RUNTIME_SCOPE_ID); + const initial = await (async () => { + try { + const code = provider.oauthStorage.authCodeRepository.create( + CLIENT.client_id, + CLIENT.redirect_uris[0], + RESOURCE, + ['tag:alpha'], + 60_000, + 'challenge', + ); + return await provider.exchangeAuthorizationCode( + CLIENT, + code, + undefined, + CLIENT.redirect_uris[0], + new URL(RESOURCE), + ); + } finally { + provider.shutdown(); + } + })(); + + const firstMarker = path.join(tempDir, 'first.entered'); + const secondMarker = path.join(tempDir, 'second.entered'); + const releasePath = path.join(tempDir, 'release-first'); + const first = runWorker(tempDir, initial.refresh_token!, firstMarker, children, releasePath); + await waitForFile(firstMarker); + + const second = runWorker(tempDir, initial.refresh_token!, secondMarker, children); + await second.started; + expect(fs.existsSync(secondMarker)).toBe(false); + + fs.writeFileSync(releasePath, 'release'); + const results = await Promise.all([first.result, second.result]); + expect(results.map((result) => result.status).sort()).toEqual(['replay', 'rotated']); + }, 20_000); +}); + +function runWorker( + storageDir: string, + refreshToken: string, + markerPath: string, + children: Set>, + releasePath?: string, +): { started: Promise; result: Promise<{ status: string }> } { + const startedPath = `${markerPath}.started`; + const child = spawn( + process.execPath, + [ + WORKER_PATH, + storageDir, + RUNTIME_SCOPE_ID, + refreshToken, + CLIENT.client_id, + startedPath, + markerPath, + releasePath ?? '', + ], + { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }, + ); + children.add(child); + + const result = new Promise<{ status: string }>((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + }); + child.once('error', reject); + child.once('exit', (code) => { + const line = stdout.split('\n').find((candidate) => candidate.startsWith('RESULT ')); + if (!line) { + reject(new Error(`Refresh worker exited ${code}: ${stderr || stdout}`)); + return; + } + resolve(JSON.parse(line.slice('RESULT '.length)) as { status: string }); + }); + }); + + return { started: waitForFile(startedPath), result }; +} + +async function waitForExit(child: ReturnType): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + await new Promise((resolve) => child.once('exit', () => resolve())); +} + +async function waitForFile(filePath: string): Promise { + const deadline = Date.now() + 5_000; + while (!fs.existsSync(filePath)) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${filePath}`); + } + await delay(20); + } +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +}