Allow PKCE instead of client secret for public clients like VSCode and Claude.ai

This commit is contained in:
Laurie Voss
2025-06-23 14:12:41 -07:00
parent 7aa36aeb26
commit f275474958
4 changed files with 58 additions and 14 deletions
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "AuthCode" ADD COLUMN "codeChallenge" TEXT,
ADD COLUMN "codeChallengeMethod" TEXT;
+2
View File
@@ -92,6 +92,8 @@ model AuthCode {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
redirectUri String
codeChallenge String?
codeChallengeMethod String?
createdAt DateTime @default(now())
}
+49 -14
View File
@@ -24,6 +24,7 @@ export async function POST(request: NextRequest) {
const redirect_uri = formData.get('redirect_uri') as string;
const client_id = formData.get('client_id') as string;
const client_secret = formData.get('client_secret') as string | null;
const code_verifier = formData.get('code_verifier') as string | undefined;
console.log("Form data:", { grant_type, code, redirect_uri, client_id });
@@ -66,20 +67,6 @@ export async function POST(request: NextRequest) {
});
}
if (client.clientSecret && client.clientSecret !== client_secret) {
console.log("Invalid client_secret.", { client_id });
return NextResponse.json({ error: 'Invalid client' }, {
status: 401,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
console.log("Found client:", client.id);
console.log("Finding auth code:", code);
const authCode = await prisma.authCode.findUnique({ where: { code } });
if (!authCode || authCode.clientId !== client.id || authCode.redirectUri !== redirect_uri) {
@@ -108,6 +95,54 @@ export async function POST(request: NextRequest) {
}
console.log("Auth code is valid.");
// PKCE validation
let pkceValid = false;
if (authCode.codeChallenge) {
if (!code_verifier) {
return NextResponse.json({ error: 'Missing code_verifier for PKCE' }, {
status: 400,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
if (authCode.codeChallengeMethod === 'S256') {
const hash = require('crypto').createHash('sha256').update(code_verifier).digest();
const base64url = hash.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
pkceValid = base64url === authCode.codeChallenge;
} else if (authCode.codeChallengeMethod === 'plain' || !authCode.codeChallengeMethod) {
pkceValid = code_verifier === authCode.codeChallenge;
}
if (!pkceValid) {
return NextResponse.json({ error: 'Invalid code_verifier for PKCE' }, {
status: 400,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
}
// If PKCE is not present or not valid, require client secret for confidential clients
if (!authCode.codeChallenge && client.clientSecret && client.clientSecret !== client_secret) {
console.log("Invalid client_secret.", { client_id });
return NextResponse.json({ error: 'Invalid client' }, {
status: 401,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
// Delete the auth code so it can't be used again
console.log("Deleting auth code:", authCode.id);
await prisma.authCode.delete({ where: { id: authCode.id } });
+4
View File
@@ -17,6 +17,8 @@ export default async function AuthorizePage({
const redirectUri = params.redirect_uri as string;
const responseType = params.response_type as string;
const state = params.state as string;
const code_challenge = params.code_challenge as string | undefined;
const code_challenge_method = params.code_challenge_method as string | undefined;
if (!session || !session.user || !session.user.id) {
const headersList = headers();
@@ -98,6 +100,8 @@ export default async function AuthorizePage({
clientId: client.id,
userId: session.user.id,
redirectUri: redirectUri,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method,
},
});