Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/adr/0011-oauth-refresh-tokens-use-rotating-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 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.
6 changes: 6 additions & 0 deletions docs/en/guide/advanced/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
6 changes: 6 additions & 0 deletions docs/zh/guide/advanced/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。
Expand Down
18 changes: 16 additions & 2 deletions src/auth/oauthAuthorizationFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<URL>;
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export interface OAuthAuthorizationFlowStorageService {
Expand All @@ -26,7 +33,14 @@ export interface OAuthAuthorizationFlowStorageService {
processConsentApproval(authRequestId: string, selectedScopes: string[]): Promise<{ redirectUrl: URL }>;
processConsentDenial(authRequestId: string): Promise<URL>;
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;
};
}

Expand Down
330 changes: 330 additions & 0 deletions src/auth/sdkOAuthServerProvider.refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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 { AUTH_CONFIG } from '@src/constants.js';
import { AgentConfigManager } from '@src/core/server/agentConfig.js';
import logger from '@src/logger/logger.js';

import { FileStorageService } from './storage/fileStorageService.js';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { SDKOAuthServerProvider } from './sdkOAuthServerProvider.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 = readOnlyFamily(tempDir);
provider.shutdown();
provider = new SDKOAuthServerProvider(tempDir, 'runtime-scope-a');

const rotated = await provider.exchangeRefreshToken(CLIENT, initial.refresh_token!);
const after = readOnlyFamily(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<string, unknown>;
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')
.mockImplementationOnce(() => {
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<FileStorageService['writeDataDurable']>[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(readOnlyFamily(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(readOnlyFamily(tempDir).status).toBe('revoked');

const successful = (
attempts.find((attempt) => attempt.status === 'fulfilled') as PromiseFulfilledResult<OAuthTokens>
).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(readOnlyFamily(tempDir).consumedTokenDigests).toHaveLength(1);
await expect(provider.exchangeRefreshToken(CLIENT, initial.refresh_token!)).rejects.toBeInstanceOf(
InvalidGrantError,
);
expect(readOnlyFamily(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<OAuthTokens> {
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 readOnlyFamily(tempDir: string): Record<string, any> {
const familyFiles = listFamilyFiles(tempDir);
expect(familyFiles).toHaveLength(1);
return JSON.parse(fs.readFileSync(familyFiles[0], 'utf8'));
}
Loading