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
+4
View File
@@ -497,6 +497,10 @@ TZ=UTC
SEED_PLATFORM_OAUTH_CLIENT_ID=
SEED_PLATFORM_OAUTH_CLIENT_SECRET=
# test oauth2 client for `packages/platform/examples/base` (optional)
SEED_OAUTH2_CLIENT_ID=
SEED_OAUTH2_CLIENT_SECRET_HASHED=
# Trigger.dev
ENABLE_ASYNC_TASKER="false" # set to "true" to enable
TRIGGER_SECRET_KEY=
@@ -597,7 +597,7 @@ export class BookingsController_2024_04_15 {
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
: undefined;
this.logger.log(`createNextApiBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
@@ -608,7 +608,7 @@ export class BookingsController_2024_04_15 {
Object.assign(clone, { userId, userUuid, ...oAuthParams, platformBookingLocation });
clone.body = {
...clone.body,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
noEmail: oAuthParams === undefined ? false : !oAuthParams.arePlatformEmailsEnabled,
creationSource: CreationSource.API_V2,
};
if (oAuthClientId) {
@@ -161,7 +161,7 @@ export class CalendarsController {
name: "calendar",
})
@UseGuards(ApiAuthGuard)
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN"])
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN", "THIRD_PARTY_ACCESS_TOKEN"])
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/:calendar/connect")
@HttpCode(HttpStatus.OK)
@@ -4,7 +4,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import { calendar_v3 } from "@googleapis/calendar";
import { Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
@@ -33,7 +33,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly calendarsService: CalendarsService,
private readonly tokensRepository: TokensRepository,
private readonly tokensService: TokensService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
@@ -145,7 +145,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
const ownerId = await this.tokensService.getAccessTokenOwnerId(accessToken);
if (!ownerId) {
throw new UnauthorizedException("Invalid Access token.");
@@ -3,7 +3,7 @@ import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
@@ -27,7 +27,7 @@ export class OutlookService implements OAuthCalendarApp {
private readonly config: ConfigService,
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly tokensService: TokensService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
@@ -178,7 +178,7 @@ export class OutlookService implements OAuthCalendarApp {
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
const ownerId = await this.tokensService.getAccessTokenOwnerId(accessToken);
if (!ownerId) {
throw new UnauthorizedException("Invalid Access token.");
@@ -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:
@@ -406,7 +406,7 @@ export const EventSetupTab = (
</div>
</div>
</Tooltip>
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && (
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && !isPlatform && (
<HostLocations eventTypeId={eventType.id} locationOptions={props.locationOptions} />
)}
</div>
@@ -0,0 +1,47 @@
---
title: "Cal OAuth Provider"
---
Cal OAuth Provider is used to setup Cal Atoms within your app after your users have authenticated using your Cal OAuth 2.0 client.
Your users will have authenticated with your Cal OAuth client and you will have access to their access tokens, which
the Cal Atoms will use to manage their event type settings, availability, etc. within your app.
It is used in the root of your app, be it _app.js or _app.tsx in case of
Next.js or App.js or App.ts in case of React. Here is an example:
```js
import "@calcom/atoms/globals.min.css";
import { CalOAuthProvider } from '@calcom/atoms';
function MyApp({ Component, pageProps }) {
const accessToken = "user-oauth-access-token";
return (
<CalOAuthProvider
accessToken={accessToken}
clientId={process.env.CAL_OAUTH2_CLIENT_ID ?? ""}
options={{
apiUrl: process.env.CAL_API_URL ?? "https://api.cal.com/v2",
refreshUrl: process.env.REFRESH_URL
}}
>
<Component {...pageProps} />
</CalOAuthProvider>
);
}
export default MyApp;
```
Below is a list of props that can be passed to the Cal OAuth Provider.
| Name | Required | Description |
|:----------------------------|:----------|:-------------------------------------------------------------------------------------------------------|
| clientId | Yes | Your OAuth2 client ID |
| options | Yes | Configuration options - `apiUrl` (should be https://api.cal.com/v2) and `refreshUrl` (URL of endpoint you have to build that to which atoms will send expired access tokens and receive new one in return. Read how to set it up [here](https://cal.com/docs/platform/quickstart#4-backend%3A-setting-up-a-refresh-token-endpoint)) and `readingDirection` (defaults to "ltr" but can also pass "rtl" which will change direction of UI components) |
| accessToken | Yes | The access token of your user for whom cal handles scheduling. |
| autoUpdateTimezone | No | Whether to automatically update managed user timezone (default: true) |
| language | No | Language code (default: "en") - available languages: "en", "de", "fr", "it", "nl", "pt-BR", "es" |
| organizationId | No | ID of your organization |
@@ -35,7 +35,7 @@ import type {
} from "./languages";
import { EN } from "./languages";
export type CalProviderProps = {
export type BaseCalProviderProps = {
children?: ReactNode;
clientId: string;
accessToken?: string;
@@ -48,6 +48,7 @@ export type CalProviderProps = {
version?: API_VERSIONS_ENUM;
organizationId?: number;
isEmbed?: boolean;
isOAuth2?: boolean;
} & i18nProps;
export function BaseCalProvider({
@@ -64,7 +65,8 @@ export function BaseCalProvider({
onTokenRefreshSuccess,
onTokenRefreshError,
isEmbed,
}: CalProviderProps) {
isOAuth2
}: BaseCalProviderProps) {
const [error, setError] = useState<string>("");
const [stateOrgId, setOrganizationId] = useState<number>(0);
@@ -89,6 +91,7 @@ export function BaseCalProvider({
const { isInit } = useOAuthClient({
isEmbed,
isOAuth2,
clientId,
apiUrl: options.apiUrl,
refreshUrl: options.refreshUrl,
@@ -167,6 +170,7 @@ export function BaseCalProvider({
},
};
return isInit ? (
<AtomsContext.Provider
value={{
@@ -0,0 +1,76 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
import { VERSION_2024_06_14 } from "@calcom/platform-constants";
import http from "../lib/http";
import type { BaseCalProviderProps } from "./BaseCalProvider";
import { BaseCalProvider } from "./BaseCalProvider";
import type { translationKeys } from "./languages";
const queryClient = new QueryClient();
type CalOAuthProviderProps = Omit<BaseCalProviderProps, "isOAuth2">;
/**
* Renders a CalProvider component.
*
* @component
* @param {string} props.clientId - The platform oauth client ID.
* @param {string} props.accessToken - The access token of your managed user. - Optional
* @param {object} props.options - The options object.
* @param {string} [options.apiUrl] - The API URL. https://api.cal.com/v2
* @param {string} [options.refreshUrl] - The url point to your refresh endpoint. - Optional, required if accessToken is provided.
* @param {boolean} [autoUpdateTimezone=true] - Whether to automatically update the timezone. - Optional
* @param {function} props.onTimezoneChange - The callback function for timezone change. - Optional
* @param {ReactNode} props.children - The child components. - Optional
* @returns {JSX.Element} The rendered CalProvider component.
*/
export function CalOAuthProvider({
clientId,
accessToken,
options,
children,
autoUpdateTimezone = true,
labels,
language = "en",
onTimezoneChange,
version = VERSION_2024_06_14,
organizationId,
isEmbed = false,
}: CalOAuthProviderProps) {
useEffect(() => {
http.setVersionHeader(version);
}, [version]);
useEffect(() => {
if (accessToken) {
queryClient.resetQueries();
}
}, [accessToken]);
useEffect(() => {
http.setPlatformEmbedHeader(isEmbed);
}, [isEmbed]);
return (
<QueryClientProvider client={queryClient}>
<BaseCalProvider
isEmbed={isEmbed}
isOAuth2={true}
autoUpdateTimezone={autoUpdateTimezone}
onTimezoneChange={onTimezoneChange}
clientId={clientId}
accessToken={accessToken}
options={options}
version={version}
labels={labels as Record<translationKeys, string>}
language={language}
organizationId={organizationId}>
{children}
</BaseCalProvider>
</QueryClientProvider>
);
}
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { useEffect } from "react";
import http from "../lib/http";
import type { CalProviderProps } from "./BaseCalProvider";
import type { BaseCalProviderProps } from "./BaseCalProvider";
import { BaseCalProvider } from "./BaseCalProvider";
import type { translationKeys } from "./languages";
@@ -38,6 +38,8 @@ const queryClient: QueryClient = new QueryClient({
},
});
type CalProviderProps = Omit<BaseCalProviderProps, "isOAuth2">;
/**
* Renders a CalProvider component.
*
@@ -89,6 +91,7 @@ export function CalProvider({
<QueryClientProvider client={queryClient}>
<BaseCalProvider
isEmbed={isEmbed}
isOAuth2={false}
autoUpdateTimezone={autoUpdateTimezone}
onTimezoneChange={onTimezoneChange}
onTokenRefreshStart={onTokenRefreshStart}
@@ -1 +1,2 @@
export { CalProvider } from "./CalProvider";
export { CalOAuthProvider } from "./CalOAuthProvider";
@@ -7,13 +7,19 @@ import type { ApiResponse } from "@calcom/platform-types";
import http from "../lib/http";
interface OAuthClientData {
clientId: string;
organizationId: number | null;
}
export interface useOAuthClientProps {
isEmbed?: boolean;
clientId: string;
apiUrl?: string;
refreshUrl?: string;
onError: (error: string) => void;
onSuccess: (data: { client: string; organizationId: number; name: string }) => void;
onSuccess: (data: OAuthClientData) => void;
isOAuth2?: boolean;
}
export const useOAuthClient = ({
isEmbed,
@@ -22,9 +28,11 @@ export const useOAuthClient = ({
refreshUrl,
onError,
onSuccess,
isOAuth2 = false,
}: useOAuthClientProps) => {
const prevClientId = usePrevious(clientId);
const [isInit, setIsInit] = useState<boolean>(false);
useEffect(() => {
if (apiUrl) {
http.setUrl(apiUrl);
@@ -38,13 +46,16 @@ export const useOAuthClient = ({
useEffect(() => {
if (!isEmbed && clientId && http.getUrl() && prevClientId !== clientId) {
try {
const fetchUrl = isOAuth2 ? `/atoms/auth/oauth2/clients/${clientId}` : `/provider/${clientId}`;
http
.get<ApiResponse<{ client: string; organizationId: number; name: string }>>(`/provider/${clientId}`)
.get<ApiResponse<OAuthClientData>>(fetchUrl)
.then((response) => {
if (response.data.status === SUCCESS_STATUS) {
onSuccess(response.data.data);
}
http.setClientIdHeader(clientId);
if (!isOAuth2) {
http.setClientIdHeader(clientId);
}
})
.catch((err: AxiosError) => {
if (err.response?.status === 401) {
+1 -1
View File
@@ -1,4 +1,4 @@
export { CalProvider } from "./cal-provider";
export { CalProvider, CalOAuthProvider } from "./cal-provider";
export { GcalConnect } from "./connect/google/GcalConnect";
export { AvailabilitySettingsPlatformWrapper as AvailabilitySettings } from "./availability";
export type { AvailabilitySettingsPlatformWrapperProps as AvailabilitySettingsProps } from "./availability/wrappers/AvailabilitySettingsPlatformWrapper";
+1 -1
View File
@@ -23,4 +23,4 @@ NEXT_PUBLIC_OAUTH2_CLIENT_ID=""
OAUTH2_CLIENT_SECRET_PLAIN=""
OAUTH2_REDIRECT_URI="http://localhost:4321"
NEXT_PUBLIC_OAUTH2_MODE=""
NEXT_PUBLIC_OAUTH2_MODE=""
@@ -0,0 +1,40 @@
This readme will guide you how to run the examples app with an OAuth 2.0 client.
## Setup
First, we need to create test OAuth client. You can do it manually by creating the following in the OAuthClient table:
{
"clientId": "1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517",
"redirectUri": "http://localhost:4321",
"clientSecret": "970db2cf14112013ba3a510b945294fef8737d42ee58c32031d2351692068ce7",
"name": "atoms examples app oauth 2 client",
"logo": null,
"clientType": "confidential",
"isTrusted": false,
"createdAt": "2026-01-22 15:50:40.722",
"purpose": "test atoms examples app with oauth 2",
"rejectionReason": null,
"status": "approved",
"userId": 10,
"websiteUrl": "http://localhost:4321"
}
Or you can do it automatically by:
In the root .env set:
SEED_OAUTH2_CLIENT_ID=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517
SEED_OAUTH2_CLIENT_SECRET_HASHED=970db2cf14112013ba3a510b945294fef8737d42ee58c32031d2351692068ce7
Then run yarn db-reset in the prisma folder which will create an OAuth client for the admin@example.com user.
Second, we need to setup environment for the examples app. Go to packages/platform/examples/base/.env and paste the following
NEXT_PUBLIC_OAUTH2_CLIENT_ID="1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517"
OAUTH2_CLIENT_SECRET_PLAIN="2df0d9b1450ea95f2376fce5bc1d352e2d7a253d7e1c68a96a44745413b7dc4c"
OAUTH2_REDIRECT_URI="http://localhost:4321"
NEXT_PUBLIC_CALCOM_API_URL="http://localhost:5555/api/v2"
NEXT_PUBLIC_OAUTH2_MODE="true"
Here we have OAUTH2_CLIENT_SECRET_PLAIN in plain because it will be used for api request when exchanging authorization code for tokens. NEXT_PUBLIC_OAUTH2_MODE tells that the examples app will run not with platform OAuth client but with OAuth 2.0 client, so in the packages/platform/examples/base/src/pages/_app.tsx we will use CalOAuthProvider and not CalProvider.
## Usage
1. Before running the example app `packages/platform/examples/base` run `rm -f prisma/dev.db && yarn prisma db push` to reset its SQLite. If you don't and there are users already then an entry for the `admin@example.com` will not be created.
2. Start web app and examples app (it is important that examples app runs on localhost:4321 because that will be redirect uri)
3. Login as admin@example.com into cal webapp using password ADMINadmin2022!
4. Visit this link http://localhost:3000/auth/oauth2/authorize?client_id=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517&redirect_uri=http://localhost:4321&state=texas or if you have setup localhost:3000 to map to app.cal.local http://app.cal.local:3000/auth/oauth2/authorize?client_id=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517&redirect_uri=http://localhost:4321&state=texas and authorize test OAuth client. You will be redirected to localhost:4321?code=abc and this route will exchange the authorization code for access and refresh tokens for the admin@example.com and store them in the examples app SQLite database.
5. Examples app is ready to use. If you update, let's say an availability, then it will be reflected in the availability of admin@example.com in the web app running locally.
@@ -1,13 +1,13 @@
// pages/_app.tsx
import type { Data } from "@/pages/api/get-managed-users";
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { Poppins } from "next/font/google";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import Select from "react-select";
import { CalProvider, BookerEmbed, Router } from "@calcom/atoms";
import { CalProvider, CalOAuthProvider, BookerEmbed, Router as CalRouter } from "@calcom/atoms";
import "@calcom/atoms/globals.min.css";
const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] });
@@ -22,7 +22,6 @@ function generateRandomEmail(name: string) {
).join("");
const randomDomain = domain[Math.floor(Math.random() * domain.length)];
return `${name}-${randomLocalPart}@${randomDomain}`;
}
@@ -34,33 +33,71 @@ export default function App({ Component, pageProps }: AppProps) {
const [email, setUserEmail] = useState("");
const [username, setUsername] = useState("");
const [selectedUser, setSelectedUser] = useState<TUser | null>(null);
const [options, setOptions] = useState([]);
const [options, setOptions] = useState<any[]>([]);
const router = useRouter();
const pathname = usePathname();
const pathname = router.pathname;
const oAuth2Mode = process.env.NEXT_PUBLIC_OAUTH2_MODE === "true";
const authorizationCode = useMemo(() => {
const code = router.query.code;
return typeof code === "string" ? code : null;
}, [router.query.code]);
useEffect(() => {
fetch("/api/get-managed-users", {
method: "get",
}).then(async (res) => {
fetch("/api/get-users", { method: "get" }).then(async (res) => {
const data = await res.json();
if (data.users.length === 1) {
setAccessToken(data.users[0].accessToken);
setUserEmail(data.users[0].email);
setUsername(data.users[0].username);
return;
}
setOptions(
data.users.map((item: Data["users"][0]) => ({ ...item, value: item.id, label: item.username }))
data.users.map((item: Data["users"][0]) => ({
...item,
value: item.id,
label: item.username,
}))
);
});
}, []);
useEffect(() => {
const randomEmailOne = generateRandomEmail("keith");
const randomEmailTwo = generateRandomEmail("somay");
const randomEmailThree = generateRandomEmail("rajiv");
const randomEmailFour = generateRandomEmail("morgan");
const randomEmailFive = generateRandomEmail("lauris");
if (!router.isReady) return;
if (seeding) return;
if (oAuth2Mode && !authorizationCode) return;
seeding = true;
if (oAuth2Mode) {
const randomEmailOne = generateRandomEmail("keith");
fetch("/api/oauth2-user", {
method: "POST",
body: JSON.stringify({
email: randomEmailOne,
authorizationCode,
}),
}).then(async (res) => {
const data = await res.json();
setAccessToken(data.accessToken);
setUserEmail(data.email);
setUsername(data.username);
});
} else {
const randomEmailOne = generateRandomEmail("keith");
const randomEmailTwo = generateRandomEmail("somay");
const randomEmailThree = generateRandomEmail("rajiv");
const randomEmailFour = generateRandomEmail("morgan");
const randomEmailFive = generateRandomEmail("lauris");
if (!seeding) {
seeding = true;
fetch("/api/managed-user", {
method: "POST",
body: JSON.stringify({
emails: [randomEmailOne, randomEmailTwo, randomEmailThree, randomEmailFour, randomEmailFive],
}),
@@ -71,7 +108,8 @@ export default function App({ Component, pageProps }: AppProps) {
setUsername(data.username);
});
}
}, []);
}, [router.isReady, oAuth2Mode, authorizationCode]);
useEffect(() => {
if (selectedUser) {
setAccessToken(selectedUser.accessToken);
@@ -84,27 +122,48 @@ export default function App({ Component, pageProps }: AppProps) {
<div className={`${poppins.className} text-black`}>
{options.length > 0 && (
<Select
defaultValue={options.find((opt: TUser | null) => opt?.email.includes("lauris"))}
defaultValue={options.find((opt: TUser | null) => opt?.email?.includes("lauris"))}
onChange={(opt: TUser | null) => setSelectedUser(opt)}
options={options}
/>
)}
<CalProvider
accessToken={accessToken}
clientId={process.env.NEXT_PUBLIC_X_CAL_ID ?? ""}
options={{ apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "", refreshUrl: "/api/refresh" }}>
{email ? (
<>
{oAuth2Mode ? (
<CalOAuthProvider
accessToken={accessToken}
clientId={process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}
options={{
apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "",
refreshUrl: "/api/refresh",
}}
>
{email ? (
<Component {...pageProps} calUsername={username} calEmail={email} />
</>
) : (
<>
<main className={`flex min-h-screen flex-col items-center justify-between p-24 `}>
) : (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex" />
</main>
</>
)}
</CalProvider>{" "}
)}
</CalOAuthProvider>
) : (
<CalProvider
accessToken={accessToken}
clientId={process.env.NEXT_PUBLIC_X_CAL_ID ?? ""}
options={{
apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "",
refreshUrl: "/api/refresh",
}}
>
{email ? (
<Component {...pageProps} calUsername={username} calEmail={email} />
) : (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex" />
</main>
)}
</CalProvider>
)}
{pathname === "/embed" && (
<div>
<BookerEmbed
@@ -129,9 +188,10 @@ export default function App({ Component, pageProps }: AppProps) {
/>
</div>
)}
{pathname === "/router" && (
<div className="p-4">
<Router
<CalRouter
formId="a63e6fce-899a-404e-8c38-e069710589c5"
formResponsesURLParams={new URLSearchParams({ isBookingDryRun: "true", Territory: "Europe" })}
onDisplayBookerEmbed={() => {
@@ -15,7 +15,7 @@ export type Data = {
// example endpoint to endpoint to fetch managed users
export default async function handler(_req: NextApiRequest, res: NextApiResponse<Data>) {
const existingUsers = await prisma.user.findMany({ orderBy: { createdAt: "desc" } });
if (existingUsers && existingUsers.length > 2) {
if (existingUsers && existingUsers.length) {
return res.status(200).json({
users: existingUsers.map((item) => ({
id: item.calcomUserId,
@@ -0,0 +1,103 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import prisma from "../../lib/prismaClient";
type Data = {
email: string;
username: string;
id: number;
accessToken: string;
};
// example endpoint to create a managed cal.com user
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
const body = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
const { email, authorizationCode } = body;
const existingUser = await prisma.user.findFirst({
orderBy: { createdAt: "desc" },
select: {
calcomUserId: true,
email: true,
calcomUsername: true,
accessToken: true,
},
});
if (existingUser && existingUser.calcomUserId) {
return res.status(200).json({
id: existingUser.calcomUserId,
email: existingUser.email,
username: existingUser.calcomUsername ?? "",
accessToken: existingUser.accessToken ?? "",
});
}
const oAuthUser = await createOAuthUser(
authorizationCode,
email,
"Keith",
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
);
return res.status(200).json(oAuthUser);
}
async function createOAuthUser(authorizationCode:string, email: string, name: string, avatarUrl: string) {
const localUser = await prisma.user.create({
data: {
email,
},
});
const exchangeResponse = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/auth/oauth2/clients/${process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}/exchange`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
origin: "http://localhost:4321",
},
body: JSON.stringify({
code: authorizationCode,
clientSecret: process.env.OAUTH2_CLIENT_SECRET_PLAIN,
redirectUri: process.env.OAUTH2_REDIRECT_URI,
}),
}
);
const exchangeResponseBody = await exchangeResponse.json();
const acccessToken = exchangeResponseBody.data.access_token;
const refreshToken = exchangeResponseBody.data.refresh_token;
const me = await fetch(
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/me`,
{
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
Authorization: `Bearer ${acccessToken}`,
},
}
);
const meResponseBody = await me.json();
await prisma.user.update({
data: {
refreshToken: refreshToken ?? "",
accessToken: acccessToken ?? "",
calcomUserId: meResponseBody.data?.id,
calcomUsername: (meResponseBody.data?.username as string) ?? "",
},
where: { id: localUser.id },
});
return {...meResponseBody.data, accessToken: acccessToken};
}
@@ -22,6 +22,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
},
});
if (localUser?.refreshToken) {
if (process.env.NEXT_PUBLIC_OAUTH2_MODE === "true") {
const oAuth2Request = await fetch(
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/auth/oauth2/clients/${process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}/refresh`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refreshToken: localUser.refreshToken,
clientSecret: process.env.OAUTH2_CLIENT_SECRET_PLAIN,
}),
}
);
if (oAuth2Request.status === 200) {
const oAuth2Response = await oAuth2Request.json();
const { access_token: newAccessToken, refresh_token: newRefreshToken } = oAuth2Response.data;
await prisma.user.update({
data: {
refreshToken: newRefreshToken as string,
accessToken: newAccessToken as string,
},
where: { id: localUser.id },
});
return res.status(200).json({ accessToken: newAccessToken });
}
return res.status(400).json({ accessToken: "" });
}
const response = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth/${
@@ -53,6 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
});
return res.status(200).json({ accessToken: newAccessToken });
}
return res.status(400).json({ accessToken: "" });
}
}
+22
View File
@@ -188,6 +188,28 @@ export async function createUserAndEventType({
return theUser;
}
type OAuthClientInput = {
clientId: string;
clientSecret: string;
name: string;
purpose: string;
redirectUri: string;
websiteUrl: string;
enablePkce: boolean;
};
export async function createOAuthClientForUser(userId: number, oAuthClient: OAuthClientInput) {
const {enablePkce, ...restOfOAuthClient} = oAuthClient;
await prisma.oAuthClient.create({
data: {
userId,
...restOfOAuthClient,
clientType: enablePkce ? "PUBLIC" : "CONFIDENTIAL",
},
});
console.log(`\t👤 Created OAuth2 client '${oAuthClient.name}' for user with id '${userId}'`);
}
export async function createTeamAndAddUsers(
teamInput: Prisma.TeamCreateInput,
users: { id: number; username: string; role?: MembershipRole }[] = []
+17 -2
View File
@@ -17,7 +17,7 @@ import type z from "zod";
import type { teamMetadataSchema } from "../packages/prisma/zod-utils";
import mainAppStore from "./seed-app-store";
import mainHugeEventTypesSeed from "./seed-huge-event-types";
import { createUserAndEventType } from "./seed-utils";
import { createOAuthClientForUser, createUserAndEventType } from "./seed-utils";
type PlatformUser = {
email: string;
@@ -1036,7 +1036,7 @@ async function main() {
},
});
await createUserAndEventType({
const admin = await createUserAndEventType({
user: {
email: "admin@example.com",
/** To comply with admin password requirements */
@@ -1047,6 +1047,21 @@ async function main() {
},
});
const clientId = process.env.SEED_OAUTH2_CLIENT_ID;
const clientSecret = process.env.SEED_OAUTH2_CLIENT_SECRET_HASHED;
if (clientId && clientSecret) {
await createOAuthClientForUser(admin.id, {
clientId,
clientSecret,
name: "atoms examples app oauth 2 client",
purpose: "test atoms examples app with oauth 2",
redirectUri: "http://localhost:4321",
websiteUrl: "http://localhost:4321",
enablePkce: false,
});
}
await createPlatformAndSetupUser({
teamInput: {
name: "Platform Team",