* 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>
120 lines
3.8 KiB
TypeScript
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);
|