feat: OAuth 2.0 support for atoms (#27158)

* fix: useOAuthClient support OAuth 2.0

* fix: cannot read properties of undefined (reading NEXT_PUBLIC_IS_E2E)

* fix: allow OAuth 2.0 token to connect gcal or ms calendar

* fix: allow OAuth 2.0 token to save gcal or ms calendar credentials

* refactor: dont set oauth id header for OAuth 2.0

* fix: calendar events not showing and emails not sent

* feat: CalOAuth2Provider

* chore: make OAuth 2.0 work in examples app

* chore: refresh OAuth 2.0 tokens

* docs: running examples app with oauth 2.0

* fix: remove sensitive console.log statements that leak secrets

Remove logging of:
- OAuth authorization codes (oauth2-user.ts)
- Token-bearing exchange responses (oauth2-user.ts)
- /me response data containing PII (oauth2-user.ts)
- OAuth2 refresh response with tokens (refresh.ts)
- Response payload with access tokens (_app.tsx)

Addresses Cubic AI review feedback for issues with confidence >= 9/10

Co-Authored-By: unknown <>

* docs: update readme

* fix: implemente cubic feedback

* fix: seed script import

* fix: seed script pkce

* fix: correct typos and SQLite capitalization in OAuth2 README (#27176)

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

* refactor: dont return name in public oauth endpoint

* docs: CalOAuthProvider

* chore: add NEXT_PUBLIC_IS_E2E constant to test

* docs: fix duplicated 'or' in Cal OAuth Provider documentation (#27177)

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

* revert: is e2e constant

* fix: typecheck

* refactor: example app users select

* update readme

* chore: update oauth atoms readme

* refactor: enable booking managed event types with user.username instead of profile.username

* fix: EventTypeSettings when viewing round robin

* test: add e2e tests for atoms-oauth2 controller

Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com>

* fix: correct error message path in atoms-oauth2 e2e test

Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
This commit is contained in:
Lauris Skraucis
2026-02-04 12:54:15 +01:00
committed by GitHub
co-authored by unknown <> cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> lauris@cal.com <lauris.skraucis@gmail.com> lauris@cal.com <lauris.skraucis@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> Rajiv Sahal
parent 0776bdf5fe
commit fc602d3b03
27 changed files with 663 additions and 65 deletions
@@ -38,6 +38,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
type EnabledAppType = App & {
credential: CredentialDataWithTeamName;
@@ -75,7 +76,8 @@ export class EventTypesAtomService {
private readonly dbRead: PrismaReadService,
private readonly eventTypeService: EventTypesService_2024_06_14,
private readonly teamEventTypeService: TeamsEventTypesService,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly usersRepository: UsersRepository,
) {}
private async getTeamSlug(teamId: number): Promise<string> {
@@ -466,26 +468,26 @@ export class EventTypesAtomService {
}): Promise<PublicEventType> {
const orgSlug = orgId ? await this.getTeamSlug(orgId) : null;
let slug: string | null = null;
let usernameOrTeamSlug: string | null = null;
if (isTeamEvent) {
if (!teamId) {
throw new BadRequestException("teamId is required for team events, please provide a valid teamId");
}
slug = await this.getTeamSlug(teamId);
usernameOrTeamSlug = await this.getTeamSlug(teamId);
} else {
if (!username) {
throw new BadRequestException(
"username is required for non-team events, please provide a valid username"
);
}
slug = username;
usernameOrTeamSlug = username;
}
const slugLower = slug.toLowerCase();
usernameOrTeamSlug = usernameOrTeamSlug.toLowerCase();
try {
const event = await getPublicEvent(
slugLower,
let event = await getPublicEvent(
usernameOrTeamSlug,
eventSlug,
isTeamEvent,
orgSlug,
@@ -493,6 +495,24 @@ export class EventTypesAtomService {
true
);
const usernamePossiblyNotFromProfile = username && orgId && !event;
if (usernamePossiblyNotFromProfile) {
const user = await this.usersRepository.findByUsernameWithProfile(username);
if (user) {
const profile = await this.usersService.getUserMainProfile(user);
if (profile?.username) {
event = await getPublicEvent(
profile.username,
eventSlug,
isTeamEvent,
orgSlug,
this.dbRead.prisma as unknown as PrismaClient,
true
);
}
}
}
if (!event) {
throw new NotFoundException(`Event type with slug ${eventSlug} not found`);
}
@@ -0,0 +1,70 @@
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture";
import { randomString } from "test/utils/randomString";
import { AppModule } from "@/app.module";
import { bootstrap } from "@/bootstrap";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
import { AuthModule } from "@/modules/auth/auth.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
describe("Atoms OAuth2 Controller Endpoints", () => {
let app: INestApplication;
let moduleRef: TestingModule;
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
const testClientId = `test-atoms-oauth-client-${randomString()}`;
const testRedirectUri = "https://example.com/callback";
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
await oAuthClientFixture.create({
clientId: testClientId,
name: "Test Atoms OAuth Client",
redirectUri: testRedirectUri,
});
});
describe("GET /api/v2/atoms/auth/oauth2/clients/:clientId", () => {
it("should return 200 and correct client ID for existing OAuth client", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/atoms/auth/oauth2/clients/${testClientId}`)
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.clientId).toBe(testClientId);
expect(response.body.data.organizationId).toBeNull();
});
it("should return 404 with error message for non-existing OAuth client", async () => {
const nonExistentClientId = `non-existent-client-${randomString()}`;
const response = await request(app.getHttpServer())
.get(`/api/v2/atoms/auth/oauth2/clients/${nonExistentClientId}`)
.expect(404);
expect(response.body.error.message).toBe("unauthorized_client");
});
});
afterAll(async () => {
await oAuthClientFixture.delete(testClientId);
await app.close();
});
});
@@ -0,0 +1,56 @@
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ErrorWithCode, getHttpStatusCode } from "@calcom/platform-libraries/errors";
import {
Controller,
Get,
HttpCode,
HttpException,
HttpStatus,
InternalServerErrorException,
Logger,
NotFoundException,
Param,
} from "@nestjs/common";
import { ApiExcludeController, ApiOperation, ApiTags } from "@nestjs/swagger";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { OAuthService } from "@/lib/services/oauth.service";
@Controller({
path: "/v2/atoms/auth/oauth2/clients/:clientId",
version: API_VERSIONS_VALUES,
})
@ApiExcludeController(true)
@ApiTags("OAuth2")
export class AtomsOAuth2Controller {
private readonly logger = new Logger("AtomsOAuth2Controller");
constructor(private readonly oAuthService: OAuthService) {}
@Get("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get a provider" })
async getClient(@Param("clientId") clientId: string) {
if (!clientId) {
throw new NotFoundException();
}
try {
const client = await this.oAuthService.getClient(clientId);
return {
status: SUCCESS_STATUS,
data: {
clientId: client.clientId,
organizationId: null,
},
};
} catch (err: unknown) {
if (err instanceof ErrorWithCode) {
const statusCode = getHttpStatusCode(err);
throw new HttpException(err.message, statusCode);
}
this.logger.error(err);
throw new InternalServerErrorException("Could not get oAuthClient");
}
}
}
@@ -1,9 +1,10 @@
import { oAuthServiceModule } from "@/lib/modules/oauth.module";
import { AtomsOAuth2Controller } from "@/modules/auth/oauth2/controllers/atoms-oauth2.controller";
import { OAuth2Controller } from "@/modules/auth/oauth2/controllers/oauth2.controller";
import { Module } from "@nestjs/common";
@Module({
imports: [oAuthServiceModule],
controllers: [OAuth2Controller],
controllers: [OAuth2Controller, AtomsOAuth2Controller],
})
export class OAuth2Module {}
@@ -1,3 +1,4 @@
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import jwt from "jsonwebtoken";
@@ -11,7 +12,22 @@ type OAuthTokenPayload = {
@Injectable()
export class TokensService {
constructor(private readonly config: ConfigService) {}
constructor(
private readonly config: ConfigService,
private readonly tokensRepository: TokensRepository
) {}
async getAccessTokenOwnerId(accessToken: string): Promise<number | null> {
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (ownerId) {
return ownerId;
}
const decodedToken = this.getDecodedThirdPartyAccessToken(accessToken);
return decodedToken?.userId ?? null;
}
getDecodedThirdPartyAccessToken(token: string): OAuthTokenPayload | null {
const encryptionKey = this.config.get<string>("CALENDSO_ENCRYPTION_KEY");
@@ -164,6 +164,22 @@ export class UsersRepository {
});
}
async findByUsernameWithProfile(username: string) {
return this.dbRead.prisma.user.findFirst({
where: { username },
include: {
movedToProfile: {
include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } },
},
profiles: {
include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } },
},
},
});
}
async findByUsername(username: string, orgSlug?: string, orgId?: number) {
return this.dbRead.prisma.user.findFirst({
where: