diff --git a/apps/api/v2/src/modules/jwt/jwt.service.ts b/apps/api/v2/src/modules/jwt/jwt.service.ts index b197636ad9..4024bb0923 100644 --- a/apps/api/v2/src/modules/jwt/jwt.service.ts +++ b/apps/api/v2/src/modules/jwt/jwt.service.ts @@ -26,6 +26,10 @@ export class JwtService { // divided by 1000 because iat (issued at time) is in seconds (not milliseconds) as informed by JWT specification return Math.floor(Date.now() / 1000); } + + decode(token: string): Payload { + return this.nestJwtService.decode(token) as Payload; + } } type Payload = Record; diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts index d8ac922477..c71bc0cebb 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -7,11 +7,13 @@ import { Locales } from "@/lib/enums/locales"; import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output"; import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output"; +import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client"; @@ -232,8 +234,28 @@ describe("OAuth Client Users Endpoints", () => { const [emailUser, emailDomain] = responseBody.data.user.email.split("@"); const [domainName, TLD] = emailDomain.split("."); expect(responseBody.data.user.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`)); - expect(responseBody.data.accessToken).toBeDefined(); - expect(responseBody.data.refreshToken).toBeDefined(); + + const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data; + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(accessTokenExpiresAt).toBeDefined(); + expect(refreshTokenExpiresAt).toBeDefined(); + + const jwtService = app.get(JwtService); + const decodedAccessToken = jwtService.decode(accessToken); + const decodedRefreshToken = jwtService.decode(refreshToken); + + expect(decodedAccessToken.clientId).toBe(oAuthClient.id); + expect(decodedAccessToken.ownerId).toBeDefined(); + expect(decodedAccessToken.type).toBe("access_token"); + expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf()); + expect(decodedAccessToken.iat).toBeGreaterThan(0); + + expect(decodedRefreshToken.clientId).toBe(oAuthClient.id); + expect(decodedRefreshToken.ownerId).toBeDefined(); + expect(decodedRefreshToken.type).toBe("refresh_token"); + expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf()); + expect(decodedRefreshToken.iat).toBeGreaterThan(0); await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 1); await userHasDefaultEventTypes(responseBody.data.user.id); @@ -273,8 +295,28 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat); expect(responseBody.data.user.locale).toEqual(requestBody.locale); expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl); - expect(responseBody.data.accessToken).toBeDefined(); - expect(responseBody.data.refreshToken).toBeDefined(); + + const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data; + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(accessTokenExpiresAt).toBeDefined(); + expect(refreshTokenExpiresAt).toBeDefined(); + + const jwtService = app.get(JwtService); + const decodedAccessToken = jwtService.decode(accessToken); + const decodedRefreshToken = jwtService.decode(refreshToken); + + expect(decodedAccessToken.clientId).toBe(oAuthClient.id); + expect(decodedAccessToken.ownerId).toBeDefined(); + expect(decodedAccessToken.type).toBe("access_token"); + expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf()); + expect(decodedAccessToken.iat).toBeGreaterThan(0); + + expect(decodedRefreshToken.clientId).toBe(oAuthClient.id); + expect(decodedRefreshToken.ownerId).toBeDefined(); + expect(decodedRefreshToken.type).toBe("refresh_token"); + expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf()); + expect(decodedRefreshToken.iat).toBeGreaterThan(0); await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 2); await userHasDefaultEventTypes(responseBody.data.user.id); @@ -314,8 +356,28 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat); expect(responseBody.data.user.locale).toEqual(requestBody.locale); expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl); - expect(responseBody.data.accessToken).toBeDefined(); - expect(responseBody.data.refreshToken).toBeDefined(); + + const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data; + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(accessTokenExpiresAt).toBeDefined(); + expect(refreshTokenExpiresAt).toBeDefined(); + + const jwtService = app.get(JwtService); + const decodedAccessToken = jwtService.decode(accessToken); + const decodedRefreshToken = jwtService.decode(refreshToken); + + expect(decodedAccessToken.clientId).toBe(oAuthClientEventTypesDisabled.id); + expect(decodedAccessToken.ownerId).toBeDefined(); + expect(decodedAccessToken.type).toBe("access_token"); + expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf()); + expect(decodedAccessToken.iat).toBeGreaterThan(0); + + expect(decodedRefreshToken.clientId).toBe(oAuthClientEventTypesDisabled.id); + expect(decodedRefreshToken.ownerId).toBeDefined(); + expect(decodedRefreshToken.type).toBe("refresh_token"); + expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf()); + expect(decodedRefreshToken.iat).toBeGreaterThan(0); await userConnectedToOAuth(oAuthClientEventTypesDisabled.id, responseBody.data.user.email, 1); await userDoesNotHaveDefaultEventTypes(responseBody.data.user.id); @@ -556,6 +618,42 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.data.locale).toEqual(Locales.PT_BR); }); + it("should force refresh tokens", async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}/force-refresh`) + .set("x-cal-secret-key", oAuthClient.secret) + .expect(200); + + const responseBody: KeysResponseDto = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.accessToken).toBeDefined(); + expect(responseBody.data.accessTokenExpiresAt).toBeDefined(); + + const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data; + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(accessTokenExpiresAt).toBeDefined(); + expect(refreshTokenExpiresAt).toBeDefined(); + + const jwtService = app.get(JwtService); + const decodedAccessToken = jwtService.decode(accessToken); + const decodedRefreshToken = jwtService.decode(refreshToken); + + expect(decodedAccessToken.clientId).toBe(oAuthClient.id); + expect(decodedAccessToken.ownerId).toBe(postResponseData.user.id); + expect(decodedAccessToken.type).toBe("access_token"); + expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf()); + expect(decodedAccessToken.iat).toBeGreaterThan(0); + + expect(decodedRefreshToken.clientId).toBe(oAuthClient.id); + expect(decodedRefreshToken.ownerId).toBe(postResponseData.user.id); + expect(decodedRefreshToken.type).toBe("refresh_token"); + expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf()); + expect(decodedRefreshToken.iat).toBeGreaterThan(0); + }); + it(`/DELETE/:id`, () => { return request(app.getHttpServer()) .delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts index d4b23dae19..e0ecd6d7e2 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -8,6 +8,7 @@ import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oau import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output"; import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output"; import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller"; import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; @@ -94,9 +95,7 @@ export class OAuthClientUsersController { status: SUCCESS_STATUS, data: { user: this.getResponseUser(user), - accessToken: tokens.accessToken, - accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(), - refreshToken: tokens.refreshToken, + ...tokens, }, }; } @@ -160,8 +159,7 @@ export class OAuthClientUsersController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Force refresh tokens", - description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. - Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`, + description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. ${TOKENS_DOCS}`, }) @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) async forceRefresh( @@ -170,21 +168,16 @@ export class OAuthClientUsersController { ): Promise { this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`); - const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId); + await this.validateManagedUserOwnership(oAuthClientId, userId); - const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens( - oAuthClientId, - id, - true + const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens( + userId, + oAuthClientId ); return { status: SUCCESS_STATUS, - data: { - accessToken, - refreshToken, - accessTokenExpiresAt: accessTokenExpiresAt.valueOf(), - }, + data: this.oAuthClientUsersService.getResponseOAuthTokens(accessToken, refreshToken), }; } diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts index 0fcc1ffe32..4099d3ca16 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts @@ -1,26 +1,18 @@ import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { IsEnum, IsNumber, IsString, ValidateNested } from "class-validator"; +import { IsEnum, ValidateNested } from "class-validator"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -export class CreateManagedUserData { +export class CreateManagedUserData extends KeysDto { @ApiProperty({ type: ManagedUserOutput, }) @ValidateNested() @Type(() => ManagedUserOutput) user!: ManagedUserOutput; - - @IsString() - accessToken!: string; - - @IsString() - refreshToken!: string; - - @IsNumber() - accessTokenExpiresAt!: number; } export class CreateManagedUserOutput { diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts index ac263a4f2c..72e5aab4fd 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts @@ -4,6 +4,7 @@ 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 { JwtService } from "@/modules/jwt/jwt.service"; import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input"; import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input"; import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; @@ -17,7 +18,6 @@ import * as request from "supertest"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; -import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; import { withNextAuth } from "test/utils/withNextAuth"; @@ -68,7 +68,7 @@ describe("OAuthFlow Endpoints", () => { let oAuthClient: PlatformOAuthClient; let authorizationCode: string | null; - let refreshToken: string; + let responseRefreshToken: string; beforeAll(async () => { const userEmail = `oauth-flow-user-${randomString()}@api.com`; @@ -153,29 +153,68 @@ describe("OAuthFlow Endpoints", () => { .send(body) .expect(200); - expect(response.body?.data?.accessToken).toBeDefined(); - expect(response.body?.data?.refreshToken).toBeDefined(); + const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data; + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(accessTokenExpiresAt).toBeDefined(); + expect(refreshTokenExpiresAt).toBeDefined(); - refreshToken = response.body.data.refreshToken; + const jwtService = app.get(JwtService); + const decodedAccessToken = jwtService.decode(accessToken); + const decodedRefreshToken = jwtService.decode(refreshToken); + + expect(decodedAccessToken.clientId).toBe(oAuthClient.id); + expect(decodedAccessToken.ownerId).toBe(user.id); + expect(decodedAccessToken.type).toBe("access_token"); + expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf()); + expect(decodedAccessToken.iat).toBeGreaterThan(0); + + expect(decodedRefreshToken.clientId).toBe(oAuthClient.id); + expect(decodedRefreshToken.ownerId).toBe(user.id); + expect(decodedRefreshToken.type).toBe("refresh_token"); + expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf()); + expect(decodedRefreshToken.iat).toBeGreaterThan(0); + + responseRefreshToken = refreshToken; }); }); describe("Refresh Token Endpoint", () => { - it("POST /oauth/:clientId/refresh", () => { + it("POST /oauth/:clientId/refresh", async () => { const secretKey = oAuthClient.secret; const body = { - refreshToken, + refreshToken: responseRefreshToken, }; - return request(app.getHttpServer()) + const response = await request(app.getHttpServer()) .post(`/v2/oauth/${oAuthClient.id}/refresh`) .set("x-cal-secret-key", secretKey) .send(body) - .expect(200) - .then((response) => { - expect(response.body?.data?.accessToken).toBeDefined(); - expect(response.body?.data?.refreshToken).toBeDefined(); - }); + .expect(200); + + const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data; + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(accessTokenExpiresAt).toBeDefined(); + expect(refreshTokenExpiresAt).toBeDefined(); + + const jwtService = app.get(JwtService); + const decodedAccessToken = jwtService.decode(accessToken); + const decodedRefreshToken = jwtService.decode(refreshToken); + + expect(decodedAccessToken.clientId).toBe(oAuthClient.id); + expect(decodedAccessToken.ownerId).toBe(user.id); + expect(decodedAccessToken.type).toBe("access_token"); + expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf()); + expect(decodedAccessToken.iat).toBeGreaterThan(0); + + expect(decodedRefreshToken.clientId).toBe(oAuthClient.id); + expect(decodedRefreshToken.ownerId).toBe(user.id); + expect(decodedRefreshToken.type).toBe("refresh_token"); + expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf()); + expect(decodedRefreshToken.iat).toBeGreaterThan(0); + + responseRefreshToken = refreshToken; }); }); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts index 37ba6fc00a..80db24b982 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts @@ -1,4 +1,3 @@ -import { getEnv } from "@/env"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; @@ -25,11 +24,7 @@ import { } from "@nestjs/common"; import { ApiTags as DocsTags, - ApiExcludeController as DocsExcludeController, - ApiOperation as DocsOperation, - ApiOkResponse as DocsOkResponse, ApiExcludeEndpoint as DocsExcludeEndpoint, - ApiBadRequestResponse as DocsBadRequestResponse, ApiHeader as DocsHeader, ApiOperation, } from "@nestjs/swagger"; @@ -37,6 +32,9 @@ import { Response as ExpressResponse } from "express"; import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; +export const TOKENS_DOCS = `Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model \`calAccessToken\` and \`calRefreshToken\` fields. +Response also contains \`accessTokenExpiresAt\` and \`refreshTokenExpiresAt\` fields, but if you decode the jwt token the payload will contain \`clientId\` (OAuth client ID), \`ownerId\` (user to whom token belongs ID), \`iat\` (issued at time) and \`expiresAt\` (when does the token expire) fields.`; + @Controller({ path: "/v2/oauth/:clientId", version: API_VERSIONS_VALUES, @@ -96,20 +94,15 @@ export class OAuthFlowController { throw new BadRequestException("Missing 'Bearer' Authorization header."); } - const { accessToken, refreshToken, accessTokenExpiresAt } = - await this.oAuthFlowService.exchangeAuthorizationToken( - authorizeEndpointCode, - clientId, - body.clientSecret - ); + const tokens = await this.oAuthFlowService.exchangeOAuthClientAuthorizationToken( + authorizeEndpointCode, + clientId, + body.clientSecret + ); return { status: SUCCESS_STATUS, - data: { - accessToken, - accessTokenExpiresAt: accessTokenExpiresAt.valueOf(), - refreshToken, - }, + data: tokens, }; } @@ -124,27 +117,19 @@ export class OAuthFlowController { }) @ApiOperation({ summary: "Refresh managed user tokens", - description: `If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and - each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`, + description: `If managed user access token is expired then get a new one using this endpoint - it will also refresh the refresh token, because we use + "refresh token rotation" mechanism. ${TOKENS_DOCS}`, }) async refreshTokens( @Param("clientId") clientId: string, @Headers(X_CAL_SECRET_KEY) secretKey: string, @Body() body: RefreshTokenInput ): Promise { - const { accessToken, refreshToken, accessTokenExpiresAt } = await this.oAuthFlowService.refreshToken( - clientId, - secretKey, - body.refreshToken - ); + const tokens = await this.oAuthFlowService.refreshUserTokens(clientId, secretKey, body.refreshToken); return { status: SUCCESS_STATUS, - data: { - accessToken: accessToken, - accessTokenExpiresAt: accessTokenExpiresAt.valueOf(), - refreshToken: refreshToken, - }, + data: tokens, }; } } diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts index 9a5f5f4bcb..f94adc275f 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts @@ -4,7 +4,7 @@ import { ValidateNested, IsEnum, IsString, IsNotEmptyObject, IsNumber } from "cl import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -class KeysDto { +export class KeysDto { @ApiProperty({ example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", }) @@ -19,6 +19,9 @@ class KeysDto { @IsNumber() accessTokenExpiresAt!: number; + + @IsNumber() + refreshTokenExpiresAt!: number; } export class KeysResponseDto { diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts index bfe69636b9..aab4e3370e 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -1,13 +1,15 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service"; import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service"; import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { Locales } from "@/lib/enums/locales"; import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input"; +import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, ConflictException, Injectable, Logger } from "@nestjs/common"; -import { User, CreationSource, PlatformOAuthClient } from "@prisma/client"; +import { User, CreationSource, PlatformOAuthClient, AccessToken, RefreshToken } from "@prisma/client"; import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries"; @@ -64,6 +66,7 @@ export class OAuthClientUsersService { timeFormat: body.timeFormat, weekStart: body.weekStart, timeZone: body.timeZone, + language: body.locale ?? Locales.EN, }) )[0]; await this.userRepository.addToOAuthClient(user.id, oAuthClientId); @@ -77,9 +80,9 @@ export class OAuthClientUsersService { user.avatarUrl = updatedUser.avatarUrl; } - const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens( - oAuthClientId, - user.id + const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + user.id, + oAuthClientId ); if (oAuthClient.areDefaultEventTypesEnabled) { @@ -100,11 +103,16 @@ export class OAuthClientUsersService { return { user, - tokens: { - accessToken, - accessTokenExpiresAt, - refreshToken, - }, + tokens: this.getResponseOAuthTokens(accessToken, refreshToken), + }; + } + + getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto { + return { + accessToken: accessToken.secret, + refreshToken: refreshToken.secret, + accessTokenExpiresAt: accessToken.expiresAt.valueOf(), + refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(), }; } diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts index ac0791ea2b..a95dfa2b42 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts @@ -1,4 +1,5 @@ import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception"; +import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { RedisService } from "@/modules/redis/redis.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; @@ -6,6 +7,7 @@ import { BadRequestException, Injectable, Logger, UnauthorizedException } from " import { DateTime } from "luxon"; import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; +import { AccessToken, RefreshToken } from "@calcom/prisma/client"; @Injectable() export class OAuthFlowService { @@ -55,8 +57,8 @@ export class OAuthFlowService { if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB"); - // await in case of race conditions, but void it's return since cache writes shouldn't halt execution. - void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour + // await in case of race conditions + await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb); // expires in 1 hour return ownerIdFromDb; } @@ -81,9 +83,8 @@ export class OAuthFlowService { } // we can't use a Promise#all or similar here because we care about execution order - // however we can't allow caches to fail a validation hence the results are voided. - void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() })); - void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000))); + await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }); + await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)); return true; } @@ -99,11 +100,11 @@ export class OAuthFlowService { return { status: "CACHE_MISS", cacheKey }; } - async exchangeAuthorizationToken( + async exchangeOAuthClientAuthorizationToken( tokenId: string, clientId: string, clientSecret: string - ): Promise<{ accessToken: string; refreshToken: string; accessTokenExpiresAt: Date }> { + ): Promise { const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens( tokenId, clientId, @@ -120,47 +121,51 @@ export class OAuthFlowService { throw new BadRequestException("Invalid Authorization Token."); } - const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens( - clientId, - authorizationToken.owner.id + const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + authorizationToken.owner.id, + clientId ); await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id); - void this.propagateAccessToken(accessToken); // void result, ignored. + await this.propagateAccessToken(accessToken.secret); - return { - accessToken, - accessTokenExpiresAt, - refreshToken, - }; + return this.getResponseOAuthTokens(accessToken, refreshToken); } - async refreshToken(clientId: string, clientSecret: string, tokenSecret: string) { + async refreshUserTokens( + clientId: string, + clientSecret: string, + userRefreshTokenSecret: string + ): Promise { const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret( clientId, clientSecret, - tokenSecret + userRefreshTokenSecret ); if (!oauthClient) { throw new BadRequestException("Invalid OAuthClient credentials."); } - const currentRefreshToken = oauthClient.refreshToken[0]; + const currentUserRefreshToken = oauthClient.refreshToken[0]; - if (!currentRefreshToken) { - throw new BadRequestException("Invalid refresh token"); + if (!currentUserRefreshToken) { + throw new BadRequestException("Invalid managed user refresh token"); } const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens( - clientId, - currentRefreshToken.secret, - currentRefreshToken.userId + currentUserRefreshToken.userId, + clientId ); + return this.getResponseOAuthTokens(accessToken, refreshToken); + } + + getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto { return { accessToken: accessToken.secret, - accessTokenExpiresAt: accessToken.expiresAt, refreshToken: refreshToken.secret, + accessTokenExpiresAt: accessToken.expiresAt.valueOf(), + refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(), }; } diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts index 448b5fde64..b21a3a34a6 100644 --- a/apps/api/v2/src/modules/tokens/tokens.repository.ts +++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts @@ -2,11 +2,14 @@ import { JwtService } from "@/modules/jwt/jwt.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { Injectable } from "@nestjs/common"; +import { Logger } from "@nestjs/common"; import { PlatformAuthorizationToken } from "@prisma/client"; import { DateTime } from "luxon"; @Injectable() export class TokensRepository { + private readonly logger = new Logger("TokensRepository"); + constructor( private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService, @@ -47,53 +50,6 @@ export class TokensRepository { }); } - async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) { - if (deleteOld) { - try { - await this.dbWrite.prisma.$transaction([ - this.dbWrite.prisma.accessToken.deleteMany({ - where: { client: { id: clientId }, userId: ownerId, expiresAt: { lte: new Date() } }, - }), - this.dbWrite.prisma.refreshToken.deleteMany({ - where: { - client: { id: clientId }, - userId: ownerId, - }, - }), - ]); - } catch (err) { - // discard. - } - } - - const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate(); - const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); - const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ - this.dbWrite.prisma.accessToken.create({ - data: { - secret: this.jwtService.signAccessToken({ clientId, ownerId }), - expiresAt: accessExpiry, - client: { connect: { id: clientId } }, - owner: { connect: { id: ownerId } }, - }, - }), - this.dbWrite.prisma.refreshToken.create({ - data: { - secret: this.jwtService.signRefreshToken({ clientId, ownerId }), - expiresAt: refreshExpiry, - client: { connect: { id: clientId } }, - owner: { connect: { id: ownerId } }, - }, - }), - ]); - - return { - accessToken: accessToken.secret, - accessTokenExpiresAt: accessToken.expiresAt, - refreshToken: refreshToken.secret, - }; - } - async getAccessTokenExpiryDate(accessTokenSecret: string) { const accessToken = await this.dbRead.prisma.accessToken.findFirst({ where: { @@ -119,34 +75,57 @@ export class TokensRepository { return accessToken?.userId; } - async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) { + async refreshOAuthTokens(ownerId: number, clientId: string) { + try { + await this.deleteOAuthTokens(ownerId, clientId); + } catch (err) { + this.logger.error("refreshOAuthTokens - Failed to delete old tokens", err); + } + + return await this.createOAuthTokens(ownerId, clientId); + } + + private async deleteOAuthTokens(ownerId: number, clientId: string) { + await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.deleteMany({ + where: { client: { id: clientId }, userId: ownerId }, + }), + this.dbWrite.prisma.refreshToken.deleteMany({ + where: { + client: { id: clientId }, + userId: ownerId, + }, + }), + ]); + } + + async createOAuthTokens(ownerId: number, clientId: string) { const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate(); const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, _refresh, accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ - this.dbWrite.prisma.accessToken.deleteMany({ - where: { client: { id: clientId }, expiresAt: { lte: new Date() } }, - }), - this.dbWrite.prisma.refreshToken.delete({ where: { secret: refreshTokenSecret } }), + const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ this.dbWrite.prisma.accessToken.create({ data: { - secret: this.jwtService.signAccessToken({ clientId, userId: tokenUserId }), + secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }), expiresAt: accessExpiry, client: { connect: { id: clientId } }, - owner: { connect: { id: tokenUserId } }, + owner: { connect: { id: ownerId } }, }, }), this.dbWrite.prisma.refreshToken.create({ data: { - secret: this.jwtService.signRefreshToken({ clientId, userId: tokenUserId }), + secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }), expiresAt: refreshExpiry, client: { connect: { id: clientId } }, - owner: { connect: { id: tokenUserId } }, + owner: { connect: { id: ownerId } }, }, }), ]); - return { accessToken, refreshToken }; + + return { + accessToken, + refreshToken, + }; } async getAccessTokenClient(accessToken: string) { diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index ee19b2a24a..329d4c877f 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -276,7 +276,7 @@ "post": { "operationId": "OAuthClientUsersController_forceRefresh", "summary": "Force refresh tokens", - "description": "If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials.\n Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have `calAccessToken` and `calRefreshToken` columns.", + "description": "If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model `calAccessToken` and `calRefreshToken` fields.\nResponse also contains `accessTokenExpiresAt` and `refreshTokenExpiresAt` fields, but if you decode the jwt token the payload will contain `clientId` (OAuth client ID), `ownerId` (user to whom token belongs ID), `iat` (issued at time) and `expiresAt` (when does the token expire) fields.", "parameters": [ { "name": "x-cal-secret-key", @@ -325,7 +325,7 @@ "post": { "operationId": "OAuthFlowController_refreshTokens", "summary": "Refresh managed user tokens", - "description": "If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and \n each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have `calAccessToken` and `calRefreshToken` columns.", + "description": "If managed user access token is expired then get a new one using this endpoint - it will also refresh the refresh token, because we use\n \"refresh token rotation\" mechanism. Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model `calAccessToken` and `calRefreshToken` fields.\nResponse also contains `accessTokenExpiresAt` and `refreshTokenExpiresAt` fields, but if you decode the jwt token the payload will contain `clientId` (OAuth client ID), `ownerId` (user to whom token belongs ID), `iat` (issued at time) and `expiresAt` (when does the token expire) fields.", "parameters": [ { "name": "clientId", @@ -9618,24 +9618,30 @@ "CreateManagedUserData": { "type": "object", "properties": { + "accessToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "refreshToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, "user": { "$ref": "#/components/schemas/ManagedUserOutput" }, - "accessToken": { - "type": "string" - }, - "refreshToken": { - "type": "string" - }, "accessTokenExpiresAt": { "type": "number" + }, + "refreshTokenExpiresAt": { + "type": "number" } }, "required": [ - "user", "accessToken", "refreshToken", - "accessTokenExpiresAt" + "user", + "accessTokenExpiresAt", + "refreshTokenExpiresAt" ] }, "CreateManagedUserOutput": { @@ -9786,12 +9792,16 @@ }, "accessTokenExpiresAt": { "type": "number" + }, + "refreshTokenExpiresAt": { + "type": "number" } }, "required": [ "accessToken", "refreshToken", - "accessTokenExpiresAt" + "accessTokenExpiresAt", + "refreshTokenExpiresAt" ] }, "KeysResponseDto": { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 788c9cc143..40e7dc995c 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -266,7 +266,7 @@ "post": { "operationId": "OAuthClientUsersController_forceRefresh", "summary": "Force refresh tokens", - "description": "If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials.\n Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have `calAccessToken` and `calRefreshToken` columns.", + "description": "If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model `calAccessToken` and `calRefreshToken` fields.\nResponse also contains `accessTokenExpiresAt` and `refreshTokenExpiresAt` fields, but if you decode the jwt token the payload will contain `clientId` (OAuth client ID), `ownerId` (user to whom token belongs ID), `iat` (issued at time) and `expiresAt` (when does the token expire) fields.", "parameters": [ { "name": "x-cal-secret-key", @@ -313,7 +313,7 @@ "post": { "operationId": "OAuthFlowController_refreshTokens", "summary": "Refresh managed user tokens", - "description": "If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and \n each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have `calAccessToken` and `calRefreshToken` columns.", + "description": "If managed user access token is expired then get a new one using this endpoint - it will also refresh the refresh token, because we use\n \"refresh token rotation\" mechanism. Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model `calAccessToken` and `calRefreshToken` fields.\nResponse also contains `accessTokenExpiresAt` and `refreshTokenExpiresAt` fields, but if you decode the jwt token the payload will contain `clientId` (OAuth client ID), `ownerId` (user to whom token belongs ID), `iat` (issued at time) and `expiresAt` (when does the token expire) fields.", "parameters": [ { "name": "clientId", @@ -9194,20 +9194,25 @@ "CreateManagedUserData": { "type": "object", "properties": { + "accessToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "refreshToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, "user": { "$ref": "#/components/schemas/ManagedUserOutput" }, - "accessToken": { - "type": "string" - }, - "refreshToken": { - "type": "string" - }, "accessTokenExpiresAt": { "type": "number" + }, + "refreshTokenExpiresAt": { + "type": "number" } }, - "required": ["user", "accessToken", "refreshToken", "accessTokenExpiresAt"] + "required": ["accessToken", "refreshToken", "user", "accessTokenExpiresAt", "refreshTokenExpiresAt"] }, "CreateManagedUserOutput": { "type": "object", @@ -9334,9 +9339,12 @@ }, "accessTokenExpiresAt": { "type": "number" + }, + "refreshTokenExpiresAt": { + "type": "number" } }, - "required": ["accessToken", "refreshToken", "accessTokenExpiresAt"] + "required": ["accessToken", "refreshToken", "accessTokenExpiresAt", "refreshTokenExpiresAt"] }, "KeysResponseDto": { "type": "object", diff --git a/packages/platform/examples/base/src/pages/booking.tsx b/packages/platform/examples/base/src/pages/booking.tsx index 502365ea6d..c9863ec9ac 100644 --- a/packages/platform/examples/base/src/pages/booking.tsx +++ b/packages/platform/examples/base/src/pages/booking.tsx @@ -131,7 +131,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string ? { isTeamEvent: true, teamId: teams?.[0]?.id || 0 } : { username: props.calUsername })} hostsLimit={3} - selectedDate={new Date("2025-03-25")} + // selectedDate={new Date("2025-03-25")} allowUpdatingUrlParams={true} />