Skip to content

Commit 4e07985

Browse files
CarinaWolliCarinaWollicubic-dev-ai[bot]
authored
feat: OAuth PKCE (calcom#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>
1 parent c95b083 commit 4e07985

19 files changed

Lines changed: 2032 additions & 108 deletions

File tree

apps/web/app/api/auth/oauth/refreshToken/route.ts

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,52 +4,71 @@ import jwt from "jsonwebtoken";
44
import { NextResponse } from "next/server";
55
import type { NextRequest } from "next/server";
66

7+
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
8+
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";
79
import prisma from "@calcom/prisma";
8-
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
910
import type { OAuthTokenPayload } from "@calcom/types/oauth";
1011

1112
async function handler(req: NextRequest) {
12-
const { client_id, client_secret, grant_type } = await parseUrlFormData(req);
13+
const { client_id, client_secret, grant_type, refresh_token, code_verifier } = await parseUrlFormData(req);
1314

14-
if (!client_id || !client_secret) {
15-
return NextResponse.json({ message: "Missing client id or secret" }, { status: 400 });
15+
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
16+
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
17+
}
18+
19+
if (!client_id) {
20+
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
1621
}
1722

1823
if (grant_type !== "refresh_token") {
19-
return NextResponse.json({ message: "grant type invalid" }, { status: 400 });
24+
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
2025
}
2126

22-
const [hashedSecret] = generateSecret(client_secret);
27+
const oAuthClientRepository = new OAuthClientRepository(prisma);
2328

24-
const client = await prisma.oAuthClient.findFirst({
25-
where: {
26-
clientId: client_id,
27-
clientSecret: hashedSecret,
28-
},
29-
select: {
30-
redirectUri: true,
31-
},
32-
});
29+
const client = await oAuthClientRepository.findByClientId(client_id);
3330

3431
if (!client) {
35-
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
32+
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
33+
}
34+
35+
const isValidClient = OAuthService.validateClient(client, client_secret);
36+
37+
if (!isValidClient) {
38+
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
3639
}
3740

38-
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
41+
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
3942

4043
let decodedRefreshToken: OAuthTokenPayload;
4144

4245
try {
43-
const refreshToken = req.headers.get("authorization")?.split(" ")[1] || "";
44-
decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload;
46+
const refreshTokenValue = refresh_token || req.headers.get("authorization")?.split(" ")[1] || "";
47+
48+
if (!refreshTokenValue) {
49+
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
50+
}
51+
52+
decodedRefreshToken = jwt.verify(refreshTokenValue, secretKey) as OAuthTokenPayload;
4553
} catch {
46-
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
54+
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
4755
}
4856

4957
if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") {
50-
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
58+
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
59+
}
60+
61+
if (decodedRefreshToken.clientId !== client_id) {
62+
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
5163
}
5264

65+
const pkceError = OAuthService.verifyPKCE(client, decodedRefreshToken, code_verifier);
66+
if (pkceError) {
67+
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
68+
}
69+
70+
const accessTokenExpiresIn = 1800; // 30 minutes
71+
5372
const payloadAuthToken: OAuthTokenPayload = {
5473
userId: decodedRefreshToken.userId,
5574
teamId: decodedRefreshToken.teamId,
@@ -64,17 +83,37 @@ async function handler(req: NextRequest) {
6483
scope: decodedRefreshToken.scope,
6584
token_type: "Refresh Token",
6685
clientId: client_id,
86+
// Preserve PKCE information for any client that used PKCE originally
87+
...(decodedRefreshToken.codeChallenge && {
88+
codeChallenge: decodedRefreshToken.codeChallenge,
89+
codeChallengeMethod: decodedRefreshToken.codeChallengeMethod,
90+
}),
6791
};
6892

6993
const access_token = jwt.sign(payloadAuthToken, secretKey, {
70-
expiresIn: 1800, // 30 min
94+
expiresIn: accessTokenExpiresIn,
7195
});
7296

73-
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
97+
const refresh_token_new = jwt.sign(payloadRefreshToken, secretKey, {
7498
expiresIn: 30 * 24 * 60 * 60, // 30 days
7599
});
76100

77-
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
101+
return NextResponse.json(
102+
{
103+
access_token,
104+
token_type: "bearer",
105+
refresh_token: refresh_token_new,
106+
expires_in: accessTokenExpiresIn,
107+
},
108+
{
109+
status: 200,
110+
headers: {
111+
"Content-Type": "application/json;charset=UTF-8",
112+
"Cache-Control": "no-store",
113+
Pragma: "no-cache",
114+
},
115+
}
116+
);
78117
}
79118

80119
export const POST = defaultResponderForAppDir(handler);

0 commit comments

Comments
 (0)