Files
calendar/apps/web/app/api/auth/oauth/refreshToken/route.ts
T
Carina WollendorferGitHubCarinaWollicubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
4e0798577a feat: OAuth PKCE (#25313)
* add public client

* implement PKCE

* pass codeChallenge and codeChallengeMethod to handler

* fixes for secure oauth flow

* fix type error

* clean up refresh token endpoint

* only support S256

* fix type error

* remove comment

* add tests

* fix type errors in route.test.ts

* add missing support for refresh token

* add e2e test for public client refresh tokens

* allow pkce for confidential clients

* fix type error

* fix e2e

* fix option pkce for confidential clients

* e2e test improvements

* fix test

* remove only

* add delay

* fix e2e tests

* remove only

* don't skip pkce if codeChallenge is set

* add service functions for token endpoint

* use service function in refreshToken endpoint

* use repository

* remove return types

* e2e test fixes

* fix e2e test

* remove .only in e2e test

* remove pause

* fix error responses in token endpoints

* adjust tests to new error responses

* fix error responses

* e2e improvements

* redirect on error

* adjust tests

* Update apps/web/modules/auth/oauth2/authorize-view.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-11-26 17:02:42 +01:00

120 lines
3.8 KiB
TypeScript

import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseUrlFormData } from "app/api/parseRequestData";
import jwt from "jsonwebtoken";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";
import prisma from "@calcom/prisma";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
async function handler(req: NextRequest) {
const { client_id, client_secret, grant_type, refresh_token, code_verifier } = await parseUrlFormData(req);
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
}
if (!client_id) {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
if (grant_type !== "refresh_token") {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
const oAuthClientRepository = new OAuthClientRepository(prisma);
const client = await oAuthClientRepository.findByClientId(client_id);
if (!client) {
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const isValidClient = OAuthService.validateClient(client, client_secret);
if (!isValidClient) {
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
let decodedRefreshToken: OAuthTokenPayload;
try {
const refreshTokenValue = refresh_token || req.headers.get("authorization")?.split(" ")[1] || "";
if (!refreshTokenValue) {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
decodedRefreshToken = jwt.verify(refreshTokenValue, secretKey) as OAuthTokenPayload;
} catch {
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") {
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
if (decodedRefreshToken.clientId !== client_id) {
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
const pkceError = OAuthService.verifyPKCE(client, decodedRefreshToken, code_verifier);
if (pkceError) {
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
}
const accessTokenExpiresIn = 1800; // 30 minutes
const payloadAuthToken: OAuthTokenPayload = {
userId: decodedRefreshToken.userId,
teamId: decodedRefreshToken.teamId,
scope: decodedRefreshToken.scope,
token_type: "Access Token",
clientId: client_id,
};
const payloadRefreshToken: OAuthTokenPayload = {
userId: decodedRefreshToken.userId,
teamId: decodedRefreshToken.teamId,
scope: decodedRefreshToken.scope,
token_type: "Refresh Token",
clientId: client_id,
// Preserve PKCE information for any client that used PKCE originally
...(decodedRefreshToken.codeChallenge && {
codeChallenge: decodedRefreshToken.codeChallenge,
codeChallengeMethod: decodedRefreshToken.codeChallengeMethod,
}),
};
const access_token = jwt.sign(payloadAuthToken, secretKey, {
expiresIn: accessTokenExpiresIn,
});
const refresh_token_new = jwt.sign(payloadRefreshToken, secretKey, {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
return NextResponse.json(
{
access_token,
token_type: "bearer",
refresh_token: refresh_token_new,
expires_in: accessTokenExpiresIn,
},
{
status: 200,
headers: {
"Content-Type": "application/json;charset=UTF-8",
"Cache-Control": "no-store",
Pragma: "no-cache",
},
}
);
}
export const POST = defaultResponderForAppDir(handler);