Skip to content

Commit 11282bb

Browse files
authored
Merge pull request #230 from PaulJPhilp/codex/pr-1-oauth-session-hardening
PR-1: OAuth/session security hardening (P0)
2 parents 0fed474 + 5ab4299 commit 11282bb

4 files changed

Lines changed: 352 additions & 28 deletions

File tree

packages/mcp-server/src/auth/__tests__/oauth-server.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,96 @@ describe("OAuth2Server client auth enforcement", () => {
162162
expect(validation.error?.error).toBe("invalid_client");
163163
});
164164
});
165+
166+
describe("OAuth2Server token lifecycle hardening", () => {
167+
it("invalidates old access token after refresh rotation", async () => {
168+
const server = new OAuth2Server(publicClientConfig) as any;
169+
const pkce = generatePKCE();
170+
171+
server.authorizationCodes.set("rotation-code", {
172+
clientId: "test-client",
173+
redirectUri: "http://localhost:3000/callback",
174+
scopes: ["mcp:access"],
175+
codeChallenge: pkce.codeChallenge,
176+
issuedAt: Date.now(),
177+
expiresAt: Date.now() + 60_000,
178+
});
179+
180+
const initial = await server.handleAuthorizationCodeGrant({
181+
grantType: "authorization_code",
182+
clientId: "test-client",
183+
code: "rotation-code",
184+
redirectUri: "http://localhost:3000/callback",
185+
codeVerifier: pkce.codeVerifier,
186+
});
187+
188+
const oldTokenReq = {
189+
headers: {
190+
authorization: `Bearer ${initial.accessToken}`,
191+
},
192+
};
193+
const oldTokenBeforeRefresh = await server.validateBearerToken(oldTokenReq);
194+
expect(oldTokenBeforeRefresh).not.toBeNull();
195+
196+
const rotated = await server.handleRefreshTokenGrant({
197+
grantType: "refresh_token",
198+
clientId: "test-client",
199+
refreshToken: initial.refreshToken,
200+
});
201+
202+
expect(rotated.accessToken).not.toBe(initial.accessToken);
203+
204+
const oldTokenAfterRefresh = await server.validateBearerToken(oldTokenReq);
205+
expect(oldTokenAfterRefresh).toBeNull();
206+
207+
const newTokenReq = {
208+
headers: {
209+
authorization: `Bearer ${rotated.accessToken}`,
210+
},
211+
};
212+
const newTokenSession = await server.validateBearerToken(newTokenReq);
213+
expect(newTokenSession).not.toBeNull();
214+
});
215+
216+
it("rejects refresh token exchange after refresh token expiry", async () => {
217+
let now = Date.now();
218+
const shortRefreshConfig: OAuthConfig = {
219+
...publicClientConfig,
220+
refreshTokenLifetime: 1,
221+
};
222+
const server = new OAuth2Server(shortRefreshConfig, {
223+
now: () => now,
224+
cleanupIntervalMs: 3600_000,
225+
}) as any;
226+
const pkce = generatePKCE();
227+
228+
server.authorizationCodes.set("expiry-code", {
229+
clientId: "test-client",
230+
redirectUri: "http://localhost:3000/callback",
231+
scopes: ["mcp:access"],
232+
codeChallenge: pkce.codeChallenge,
233+
issuedAt: now,
234+
expiresAt: now + 60_000,
235+
});
236+
237+
const issued = await server.handleAuthorizationCodeGrant({
238+
grantType: "authorization_code",
239+
clientId: "test-client",
240+
code: "expiry-code",
241+
redirectUri: "http://localhost:3000/callback",
242+
codeVerifier: pkce.codeVerifier,
243+
});
244+
245+
now += 1_100;
246+
247+
await expect(
248+
server.handleRefreshTokenGrant({
249+
grantType: "refresh_token",
250+
clientId: "test-client",
251+
refreshToken: issued.refreshToken,
252+
}),
253+
).rejects.toThrow("invalid_grant");
254+
255+
expect(server.sessions.size).toBe(0);
256+
});
257+
});

packages/mcp-server/src/auth/oauth-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ export interface AuthenticatedSession {
8080
clientId: string;
8181
userId?: string;
8282
scopes: string[];
83+
issuedAt: number;
8384
expiresAt: number;
85+
refreshTokenExpiresAt?: number;
8486
accessToken: string;
8587
refreshToken?: string;
8688
}

0 commit comments

Comments
 (0)