Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
3c4b28c
refactor: combine exchange and refresh into token endpoint
supalarry Jan 30, 2026
d00045d
refactor: controller error handling
supalarry Jan 30, 2026
93944cd
refactor: use snake_case
supalarry Jan 30, 2026
6eedbc5
refactor: use snake_case
supalarry Jan 30, 2026
11a88f7
refactor: use snake case
supalarry Jan 30, 2026
018da45
refactor: token endpoint accepts application/x-www-form-urlencoded
supalarry Jan 30, 2026
0e546d8
refactor: token endpoint accepts application/x-www-form-urlencoded
supalarry Jan 30, 2026
02892e6
refactor: flat token response data
supalarry Jan 30, 2026
4a30760
refactor: error structure
supalarry Jan 30, 2026
095bf29
refactor: client_id in the body
supalarry Jan 30, 2026
97bf593
fix: address Cubic AI review feedback on OAuth2 endpoints
devin-ai-integration[bot] Jan 30, 2026
39cc4aa
fix: address additional Cubic AI feedback on OAuth2 endpoints
devin-ai-integration[bot] Jan 30, 2026
bb6f610
docs
supalarry Jan 30, 2026
8c20884
Merge branch 'main' into lauris/cal-7090-test-oauth-v2-endpoints
supalarry Feb 2, 2026
34828ae
Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints"
supalarry Feb 2, 2026
062a051
Revert "fix: address Cubic AI review feedback on OAuth2 endpoints"
supalarry Feb 2, 2026
3ed5c64
docs
supalarry Feb 2, 2026
04986a1
fix: address Cubic AI review feedback on OAuth2 endpoints
devin-ai-integration[bot] Feb 2, 2026
a080e93
fix: address additional Cubic AI feedback on OAuth2 endpoints
devin-ai-integration[bot] Feb 2, 2026
6e1faf3
e2e
supalarry Feb 2, 2026
0c6da4a
Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints"
supalarry Feb 2, 2026
492c8f9
Revert "fix: address Cubic AI review feedback on OAuth2 endpoints"
supalarry Feb 2, 2026
efb1308
Merge branch 'main' into lauris/cal-7090-test-oauth-v2-endpoints
supalarry Feb 2, 2026
416bef9
fix: re-apply Cubic AI review feedback on OAuth2 endpoints
devin-ai-integration[bot] Feb 2, 2026
ba51fa1
Merge branch 'lauris/cal-7090-test-oauth-v2-endpoints' of https://git…
devin-ai-integration[bot] Feb 2, 2026
3addeab
Revert "fix: re-apply Cubic AI review feedback on OAuth2 endpoints"
supalarry Feb 2, 2026
ca896d2
delete unused file
supalarry Feb 2, 2026
f978d4f
fix: e2e tests
supalarry Feb 2, 2026
32fb028
address cubic review
supalarry Feb 2, 2026
e4dcaf2
Merge branch 'main' into lauris/cal-7090-test-oauth-v2-endpoints
supalarry Feb 2, 2026
1ebcfac
fix: address Cubic AI review feedback on OAuth2 exception filter
devin-ai-integration[bot] Feb 2, 2026
fb68b61
docs: api v2 oauth controller docs
supalarry Feb 2, 2026
5f1286c
chore: remove authorize endpoint
supalarry Feb 2, 2026
c0ec8e6
Merge branch 'main' into lauris/cal-7090-test-oauth-v2-endpoints
supalarry Feb 2, 2026
223b222
feat: owner can test non accepted OAuth client
supalarry Feb 2, 2026
b1698ea
fix: remove sensitive data from OAuth2 exception logs
devin-ai-integration[bot] Feb 2, 2026
2faa018
fix: e2e
supalarry Feb 3, 2026
2e4e905
fix: revoke OAuth 2.0 old refresh tokens when refreshing
supalarry Feb 3, 2026
94c0428
fix: distinguish undefined vs null in rotateToken where clause
devin-ai-integration[bot] Feb 3, 2026
d9bd47e
Merge branch 'main' into lauris/cal-7029-feat-enable-test-access-whil…
supalarry Feb 5, 2026
c307172
refactor: be more careful with deleteMany
supalarry Feb 5, 2026
edd2a2a
Merge branch 'main' into lauris/cal-7029-feat-enable-test-access-whil…
supalarry Feb 5, 2026
2cd71cd
Merge branch 'lauris/cal-7029-feat-enable-test-access-while-oauth-cli…
supalarry Feb 5, 2026
e7343d7
refactor: throw error if userid or teamid not in token
supalarry Feb 9, 2026
d6dac08
fix: use typed ErrorWithCode instead of generic Error for OAuth error…
devin-ai-integration[bot] Feb 9, 2026
4aa0b02
refactor: rejected client cant be used by owner
supalarry Feb 10, 2026
68ff4c2
refactor: pending oauth client warning message
supalarry Feb 10, 2026
0e45976
refactor: rejected oauth client message
supalarry Feb 10, 2026
acf5a0c
refactor: always store userId in access code
supalarry Feb 10, 2026
5ce8e05
Merge branch 'main' into lauris/cal-7029-feat-enable-test-access-whil…
supalarry Feb 10, 2026
ade34b9
Revert "refactor: always store userId in access code"
supalarry Feb 10, 2026
d5ca049
fix test
supalarry Feb 10, 2026
834dc3c
Merge branch 'main' into lauris/cal-7029-feat-enable-test-access-whil…
supalarry Feb 10, 2026
3f55ba3
Merge branch 'main' into lauris/cal-7029-feat-enable-test-access-whil…
supalarry Feb 11, 2026
23b2af5
Merge branch 'main' into lauris/cal-7029-feat-enable-test-access-whil…
supalarry Feb 12, 2026
51fedb2
Merge branch 'lauris/cal-7029-feat-enable-test-access-while-oauth-cli…
supalarry Feb 12, 2026
1407772
Merge branch 'main' into lauris/cal-7029-feat-enable-test-access-whil…
supalarry Feb 13, 2026
0970fc8
Merge branch 'lauris/cal-7029-feat-enable-test-access-while-oauth-cli…
supalarry Feb 13, 2026
7e08906
Merge branch 'main' into lauris/cal-7035-fix-invalidate-old-refresh-t…
supalarry Feb 19, 2026
5e56c63
chore: finish main merge
supalarry Feb 19, 2026
5005f76
chore: finish merge main
supalarry Feb 19, 2026
a336fed
fix: test
supalarry Feb 19, 2026
ca3c00a
Merge branch 'main' into lauris/cal-7035-fix-invalidate-old-refresh-t…
supalarry Feb 20, 2026
9da7ed3
Merge branch 'main' into lauris/cal-7035-fix-invalidate-old-refresh-t…
supalarry Feb 23, 2026
a5f6921
Merge branch 'main' into lauris/cal-7035-fix-invalidate-old-refresh-t…
supalarry Feb 24, 2026
48be831
Merge branch 'main' into lauris/cal-7035-fix-invalidate-old-refresh-t…
supalarry Feb 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/api/v2/src/lib/modules/oauth.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { PrismaAccessCodeRepository } from "@/lib/repositories/prisma-access-code.repository";
import { PrismaOAuthClientRepository } from "@/lib/repositories/prisma-oauth-client.repository";
import { PrismaOAuthRefreshTokenRepository } from "@/lib/repositories/prisma-oauth-refresh-token.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { OAuthService } from "@/lib/services/oauth.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";

@Module({
imports: [PrismaModule],
providers: [PrismaAccessCodeRepository, PrismaOAuthClientRepository, PrismaTeamRepository, OAuthService],
providers: [
PrismaAccessCodeRepository,
PrismaOAuthClientRepository,
PrismaOAuthRefreshTokenRepository,
PrismaTeamRepository,
OAuthService,
],
exports: [OAuthService],
})
export class oAuthServiceModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";

import { PrismaOAuthRefreshTokenRepository as PrismaOAuthRefreshTokenRepositoryLib } from "@calcom/platform-libraries/repositories";
import type { PrismaClient } from "@calcom/prisma";

@Injectable()
export class PrismaOAuthRefreshTokenRepository extends PrismaOAuthRefreshTokenRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
3 changes: 3 additions & 0 deletions apps/api/v2/src/lib/services/oauth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { OAuthService as BaseOAuthService } from "@calcom/platform-libraries";
import { Injectable } from "@nestjs/common";
import { PrismaAccessCodeRepository } from "@/lib/repositories/prisma-access-code.repository";
import { PrismaOAuthClientRepository } from "@/lib/repositories/prisma-oauth-client.repository";
import { PrismaOAuthRefreshTokenRepository } from "@/lib/repositories/prisma-oauth-refresh-token.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import type { OAuth2ExchangeInput } from "@/modules/auth/oauth2/inputs/exchange.input";
import { OAuth2ExchangeConfidentialInput } from "@/modules/auth/oauth2/inputs/exchange.input";
Expand All @@ -15,11 +16,13 @@ export class OAuthService extends BaseOAuthService {
constructor(
accessCodeRepository: PrismaAccessCodeRepository,
oAuthClientRepository: PrismaOAuthClientRepository,
oAuthRefreshTokenRepository: PrismaOAuthRefreshTokenRepository,
teamsRepository: PrismaTeamRepository
) {
super({
accessCodeRepository: accessCodeRepository,
oAuthClientRepository: oAuthClientRepository,
oAuthRefreshTokenRepository: oAuthRefreshTokenRepository,
teamsRepository: teamsRepository,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { INestApplication } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import jwt from "jsonwebtoken";
import request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
Expand Down Expand Up @@ -91,16 +92,15 @@ describe("OAuth2 Controller Endpoints", () => {

/** Generate an authorization code directly via the service (bypasses HTTP layer). */
async function generateAuthCode(
scopes: AccessScope[] = [AccessScope.READ_BOOKING],
teamSlug?: string
scopes: AccessScope[] = [AccessScope.READ_BOOKING]
): Promise<string> {
const result = await oAuthService.generateAuthorizationCode(
testClientId,
authenticatedUser.id,
testRedirectUri,
scopes,
undefined,
teamSlug ?? team.slug
team.slug ?? undefined
);
const redirectUrl = new URL(result.redirectUrl);
return redirectUrl.searchParams.get("code") as string;
Expand Down Expand Up @@ -381,6 +381,113 @@ describe("OAuth2 Controller Endpoints", () => {
expect(response.body.refresh_token).toBeDefined();
expect(response.body.token_type).toBe("bearer");
expect(response.body.expires_in).toBe(1800);

refreshToken = response.body.refresh_token;
});

it("should reject reuse of old refresh token after rotation", async () => {
// Get a fresh token pair
const code = await generateAuthCode();
const tokenResponse = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);

const oldRefreshToken = tokenResponse.body.refresh_token;

// Use the refresh token once (rotates it)
await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "refresh_token",
refresh_token: oldRefreshToken,
client_secret: testClientSecret,
})
.expect(200);

// Try to reuse the old refresh token — should be rejected
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "refresh_token",
refresh_token: oldRefreshToken,
client_secret: testClientSecret,
})
.expect(400);

expect(response.body.error).toBe("invalid_grant");
expect(response.body.error_description).toBe("refresh_token_revoked");
});

it("should accept legacy refresh token without secret and reject it after rotation", async () => {
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
// Sign a legacy refresh token (no secret field)
const legacyRefreshToken = jwt.sign(
{
userId: authenticatedUser.id,
teamId: team.id,
scope: [AccessScope.READ_BOOKING],
token_type: "Refresh Token",
clientId: testClientId,
},
secretKey!,
{ expiresIn: 30 * 24 * 60 * 60 }
);

// Legacy token should be accepted (one-time pass)
const firstResponse = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "refresh_token",
refresh_token: legacyRefreshToken,
client_secret: testClientSecret,
})
.expect(200);

expect(firstResponse.body.access_token).toBeDefined();
expect(firstResponse.body.refresh_token).toBeDefined();

const newRefreshToken = firstResponse.body.refresh_token;

// Use the new token (has secret, triggers rotation)
await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "refresh_token",
refresh_token: newRefreshToken,
client_secret: testClientSecret,
})
.expect(200);

// Reuse the new token — should be rejected
const reuseResponse = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "refresh_token",
refresh_token: newRefreshToken,
client_secret: testClientSecret,
})
.expect(400);

expect(reuseResponse.body.error).toBe("invalid_grant");
expect(reuseResponse.body.error_description).toBe("refresh_token_revoked");
});
});

Expand Down
8 changes: 7 additions & 1 deletion apps/web/app/api/auth/oauth/refreshToken/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ async function handler(req: NextRequest) {
);
} catch (err) {
if (err instanceof ErrorWithCode) {
return NextResponse.json({ error: err.message }, { status: getHttpStatusCode(err) });
return NextResponse.json(
{
error: err.message,
error_description: (err.data?.reason as string | undefined) ?? err.message,
},
{ status: getHttpStatusCode(err) }
);
}
return NextResponse.json({ error: "server_error" }, { status: 500 });
}
Expand Down
81 changes: 80 additions & 1 deletion apps/web/playwright/oauth/oauth-refresh-tokens.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import jwt from "jsonwebtoken";
import process from "node:process";

import { test } from "../lib/fixtures";
import type { PrismaClient } from "@calcom/prisma";
import jwt from "jsonwebtoken";

test.describe("OAuth - refresh tokens", () => {
test.afterEach(async ({ prisma, users }, testInfo) => {
Expand Down Expand Up @@ -66,6 +67,84 @@ test.describe("OAuth - refresh tokens", () => {
);
}

test("legacy refresh token without secret is accepted", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-refresh-legacy" });
await user.apiLogin();

const testPrefix = `e2e-oauth-refresh-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}approved-${Date.now()}`,
status: "APPROVED",
clientType: "PUBLIC",
});

// Legacy token has no secret field — should be accepted once
const legacyToken = signRefreshToken(client.clientId, user.id);

const response = await page.request.post("/api/auth/oauth/refreshToken", {
form: {
grant_type: "refresh_token",
client_id: client.clientId,
refresh_token: legacyToken,
},
});
expect(response.status()).toBe(200);

const json = await response.json();
expect(json.access_token).toBeDefined();
expect(json.refresh_token).toBeDefined();
});

test("reusing a rotated refresh token is rejected", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-refresh-rotation" });
await user.apiLogin();

const testPrefix = `e2e-oauth-refresh-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}approved-${Date.now()}`,
status: "APPROVED",
clientType: "PUBLIC",
});

// Use a legacy token to get a new-format token with a secret
const legacyToken = signRefreshToken(client.clientId, user.id);
const firstResponse = await page.request.post("/api/auth/oauth/refreshToken", {
form: {
grant_type: "refresh_token",
client_id: client.clientId,
refresh_token: legacyToken,
},
});
expect(firstResponse.status()).toBe(200);
const newRefreshToken = (await firstResponse.json()).refresh_token;

// Use the new token once (rotates it)
const secondResponse = await page.request.post("/api/auth/oauth/refreshToken", {
form: {
grant_type: "refresh_token",
client_id: client.clientId,
refresh_token: newRefreshToken,
},
});
expect(secondResponse.status()).toBe(200);

// Reuse the same token — should be rejected
const reuseResponse = await page.request.post("/api/auth/oauth/refreshToken", {
form: {
grant_type: "refresh_token",
client_id: client.clientId,
refresh_token: newRefreshToken,
},
});
expect(reuseResponse.status()).toBe(400);

const reuseJson = await reuseResponse.json();
expect(reuseJson.error).toBe("invalid_grant");
expect(reuseJson.error_description).toBe("refresh_token_revoked");
});

test("token refresh fails if client is not approved", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-refresh-status-check" });
await user.apiLogin();
Expand Down
23 changes: 23 additions & 0 deletions packages/features/oauth/di/OAuthRefreshTokenRepository.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { OAuthRefreshTokenRepository } from "@calcom/features/oauth/repositories/OAuthRefreshTokenRepository";
import { OAUTH_DI_TOKENS } from "./tokens";

const thisModule = createModule();
const token = OAUTH_DI_TOKENS.OAUTH_REFRESH_TOKEN_REPOSITORY;
const moduleToken = OAUTH_DI_TOKENS.OAUTH_REFRESH_TOKEN_REPOSITORY_MODULE;

const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: OAuthRefreshTokenRepository,
dep: prismaModuleLoader,
});

export const moduleLoader: ModuleLoader = {
token,
loadModule,
};

export type { OAuthRefreshTokenRepository };
3 changes: 2 additions & 1 deletion packages/features/oauth/di/OAuthService.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";

import { moduleLoader as accessCodeRepositoryModuleLoader } from "./AccessCodeRepository.module";
import { moduleLoader as oAuthClientRepositoryModuleLoader } from "./OAuthClientRepository.module";
import { moduleLoader as oAuthRefreshTokenRepositoryModuleLoader } from "./OAuthRefreshTokenRepository.module";
import { moduleLoader as teamRepositoryModuleLoader } from "./TeamRepository.module";
import { OAUTH_DI_TOKENS } from "./tokens";

Expand All @@ -18,6 +18,7 @@ const loadModule = bindModuleToClassOnToken({
depsMap: {
oAuthClientRepository: oAuthClientRepositoryModuleLoader,
accessCodeRepository: accessCodeRepositoryModuleLoader,
oAuthRefreshTokenRepository: oAuthRefreshTokenRepositoryModuleLoader,
teamsRepository: teamRepositoryModuleLoader,
},
});
Expand Down
2 changes: 2 additions & 0 deletions packages/features/oauth/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export const OAUTH_DI_TOKENS = {
OAUTH_CLIENT_REPOSITORY_MODULE: Symbol("OAuthClientRepositoryModule"),
ACCESS_CODE_REPOSITORY: Symbol("AccessCodeRepository"),
ACCESS_CODE_REPOSITORY_MODULE: Symbol("AccessCodeRepositoryModule"),
OAUTH_REFRESH_TOKEN_REPOSITORY: Symbol("OAuthRefreshTokenRepository"),
OAUTH_REFRESH_TOKEN_REPOSITORY_MODULE: Symbol("OAuthRefreshTokenRepositoryModule"),
OAUTH_SERVICE: Symbol("OAuthService"),
OAUTH_SERVICE_MODULE: Symbol("OAuthServiceModule"),
};
Loading
Loading