diff --git a/prisma/migrations/20250623205959_add_pkce_to_auth_code/migration.sql b/prisma/migrations/20250623205959_add_pkce_to_auth_code/migration.sql new file mode 100644 index 0000000..e8a05fc --- /dev/null +++ b/prisma/migrations/20250623205959_add_pkce_to_auth_code/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "AuthCode" ADD COLUMN "codeChallenge" TEXT, +ADD COLUMN "codeChallengeMethod" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07ec592..cf06f9e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) } diff --git a/src/app/api/oauth/token/route.ts b/src/app/api/oauth/token/route.ts index 67f80df..bdd3e3f 100644 --- a/src/app/api/oauth/token/route.ts +++ b/src/app/api/oauth/token/route.ts @@ -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 } }); diff --git a/src/app/oauth/authorize/page.tsx b/src/app/oauth/authorize/page.tsx index 6454239..66b56c5 100644 --- a/src/app/oauth/authorize/page.tsx +++ b/src/app/oauth/authorize/page.tsx @@ -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, }, });