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:
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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user