diff --git a/src/app/oauth/token/route.ts b/src/app/oauth/token/route.ts index 4d1b7c5..a2df7f5 100644 --- a/src/app/oauth/token/route.ts +++ b/src/app/oauth/token/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { hashOpaqueToken, issueTokenPair, + refreshClientMatches, verifyPkce, } from "@/lib/oauth"; import { prisma } from "@/lib/prisma"; @@ -60,7 +61,7 @@ export async function POST(req: NextRequest) { if (grantType === "refresh_token") { const rawRefresh = params.get("refresh_token") || ""; - if (!rawRefresh || !clientId) return oauthError("invalid_request", "refresh_token and client_id are required"); + if (!rawRefresh) return oauthError("invalid_request", "refresh_token is required"); const refresh = await prisma.oAuthToken.findUnique({ where: { tokenHash: hashOpaqueToken(rawRefresh) }, include: { grant: { include: { client: true } } }, @@ -71,7 +72,11 @@ export async function POST(req: NextRequest) { refresh.revokedAt || refresh.expiresAt <= new Date() || refresh.grant.revokedAt || - refresh.grant.client.clientId !== clientId + !refreshClientMatches({ + expectedClientId: refresh.grant.client.clientId, + tokenEndpointAuthMethod: refresh.grant.client.tokenEndpointAuthMethod, + presentedClientId: clientId, + }) ) { return oauthError("invalid_grant", "Refresh token is invalid, expired, or revoked"); } diff --git a/src/lib/oauth.test.ts b/src/lib/oauth.test.ts index da3357b..eb561dc 100644 --- a/src/lib/oauth.test.ts +++ b/src/lib/oauth.test.ts @@ -4,6 +4,7 @@ import { isAllowedRedirectUri, normalizeScope, pkceChallenge, + refreshClientMatches, verifyPkce, } from "./oauth"; @@ -26,4 +27,33 @@ describe("agent OAuth primitives", () => { expect(isAllowedRedirectUri("https://user:pass@example.com/callback")).toBe(false); expect(isAllowedRedirectUri("https://example.com/callback#fragment")).toBe(false); }); + + it("allows a public PKCE client to refresh without repeating client_id", () => { + expect(refreshClientMatches({ + expectedClientId: "gc_client_chatgpt", + tokenEndpointAuthMethod: "none", + presentedClientId: "", + })).toBe(true); + }); + + it("still enforces client binding when client_id is supplied", () => { + expect(refreshClientMatches({ + expectedClientId: "gc_client_chatgpt", + tokenEndpointAuthMethod: "none", + presentedClientId: "gc_client_chatgpt", + })).toBe(true); + expect(refreshClientMatches({ + expectedClientId: "gc_client_chatgpt", + tokenEndpointAuthMethod: "none", + presentedClientId: "another-client", + })).toBe(false); + }); + + it("does not allow a client that requires token-endpoint authentication to omit identity", () => { + expect(refreshClientMatches({ + expectedClientId: "confidential-client", + tokenEndpointAuthMethod: "client_secret_basic", + presentedClientId: "", + })).toBe(false); + }); }); diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 891d9b4..207dba6 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -144,6 +144,21 @@ export function isAllowedRedirectUri(value: string): boolean { } } +export function refreshClientMatches(input: { + expectedClientId: string; + tokenEndpointAuthMethod: string; + presentedClientId?: string | null; +}) { + const presented = String(input.presentedClientId || "").trim(); + + // Public OAuth clients using token_endpoint_auth_method=none cannot + // authenticate themselves at refresh time. The refresh token is already + // bound to its grant/client in GroundControl, so client_id is optional. + // If a client_id is presented, it must still match exactly. + if (!presented) return input.tokenEndpointAuthMethod === "none"; + return presented === input.expectedClientId; +} + export function parseRedirectUris(value: string): string[] { try { const parsed = JSON.parse(value);