Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 7 additions & 2 deletions src/app/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
hashOpaqueToken,
issueTokenPair,
refreshClientMatches,
verifyPkce,
} from "@/lib/oauth";
import { prisma } from "@/lib/prisma";
Expand Down Expand Up @@ -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 } } },
Expand All @@ -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");
}
Expand Down
30 changes: 30 additions & 0 deletions src/lib/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isAllowedRedirectUri,
normalizeScope,
pkceChallenge,
refreshClientMatches,
verifyPkce,
} from "./oauth";

Expand All @@ -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);
});
});
15 changes: 15 additions & 0 deletions src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading