From db84343581707bb0d73c31e380b3703033eacf17 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 9 Jul 2025 22:47:07 +0300 Subject: [PATCH] chore: new auth strategy for third party access tokens (#21867) * chore: new auth strategy for third party access tokens * chore: throw error earlier if Bearer token is invalid * fix: check apikey and access token before third party token --- apps/api/v2/src/lib/enums/auth-methods.ts | 1 + .../api-auth-guard-only-allow.decorator.ts | 7 +- .../strategies/api-auth/api-auth.strategy.ts | 117 ++++++++++++++++-- .../v2/src/modules/users/users.repository.ts | 21 ++++ 4 files changed, 137 insertions(+), 9 deletions(-) diff --git a/apps/api/v2/src/lib/enums/auth-methods.ts b/apps/api/v2/src/lib/enums/auth-methods.ts index 282aa7462d..4edfb7511f 100644 --- a/apps/api/v2/src/lib/enums/auth-methods.ts +++ b/apps/api/v2/src/lib/enums/auth-methods.ts @@ -3,4 +3,5 @@ export enum AuthMethods { "ACCESS_TOKEN" = "access-token", "OAUTH_CLIENT" = "oauth-client", "NEXT_AUTH" = "next-auth", + "THIRD_PARTY_ACCESS_TOKEN" = "third_party_access_token", } diff --git a/apps/api/v2/src/modules/auth/decorators/api-auth-guard-only-allow.decorator.ts b/apps/api/v2/src/modules/auth/decorators/api-auth-guard-only-allow.decorator.ts index cd294395c6..5f6b470967 100644 --- a/apps/api/v2/src/modules/auth/decorators/api-auth-guard-only-allow.decorator.ts +++ b/apps/api/v2/src/modules/auth/decorators/api-auth-guard-only-allow.decorator.ts @@ -1,5 +1,10 @@ import { Reflector } from "@nestjs/core"; -export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH"; +export type AllowedAuthMethod = + | "OAUTH_CLIENT_CREDENTIALS" + | "API_KEY" + | "ACCESS_TOKEN" + | "NEXT_AUTH" + | "THIRD_PARTY_ACCESS_TOKEN"; export const ApiAuthGuardOnlyAllow = Reflector.createDecorator(); diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts index 00692b7772..17538140fd 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts @@ -10,16 +10,30 @@ import { ProfilesRepository } from "@/modules/profiles/profiles.repository"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersService } from "@/modules/users/services/users.service"; import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; -import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common"; +import { + HttpException, + Injectable, + InternalServerErrorException, + UnauthorizedException, +} from "@nestjs/common"; +import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import type { Request } from "express"; +import * as jwt from "jsonwebtoken"; import { getToken } from "next-auth/jwt"; import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator"; +interface OAuthTokenPayload { + userId?: number; + teamId?: number; + scope: string[]; + token_type: string; +} + export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean }; export type ApiAuthGuardRequest = Request & { authMethod: AuthMethods; @@ -29,8 +43,11 @@ export type ApiAuthGuardRequest = Request & { }; export const NO_AUTH_PROVIDED_MESSAGE = "No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"; + @Injectable() export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") { + private readonly logger = new Logger("ApiAuthStrategy"); + constructor( private readonly deploymentsService: DeploymentsService, private readonly config: ConfigService, @@ -54,22 +71,52 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") const allowedMethods = request.allowedAuthMethods; const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length; + const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS"); const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY"); const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN"); const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH"); + const thirdPartyAccessTokenAllowed = + noSpecificAuthExpected || allowedMethods.includes("THIRD_PARTY_ACCESS_TOKEN"); if (oAuthClientId && oAuthClientSecret && oAuthAllowed) { request.authMethod = AuthMethods["OAUTH_CLIENT"]; return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request); } - if (bearerToken && (apiKeyAllowed || accessTokenAllowed)) { - const requestOrigin = request.get("Origin"); - request.authMethod = isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_") - ? AuthMethods["API_KEY"] - : AuthMethods["ACCESS_TOKEN"]; - return await this.authenticateBearerToken(bearerToken, request, requestOrigin); + if (bearerToken) { + if (!apiKeyAllowed && !accessTokenAllowed && thirdPartyAccessTokenAllowed) { + request.authMethod = AuthMethods["THIRD_PARTY_ACCESS_TOKEN"]; + const result = await this.validateThirdPartyAccessToken(bearerToken, request); + if (result.success) { + return this.success(this.getSuccessUser(result.data)); + } + } + + if (apiKeyAllowed || accessTokenAllowed) { + try { + const requestOrigin = request.get("Origin"); + request.authMethod = isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_") + ? AuthMethods["API_KEY"] + : AuthMethods["ACCESS_TOKEN"]; + return await this.authenticateBearerToken(bearerToken, request, requestOrigin); + } catch (err) { + // failed to validate access token, try to validate third party token + if (thirdPartyAccessTokenAllowed && request.authMethod === AuthMethods["ACCESS_TOKEN"]) { + request.authMethod = AuthMethods["THIRD_PARTY_ACCESS_TOKEN"]; + const result = await this.validateThirdPartyAccessToken(bearerToken, request); + if (result.success) { + return this.success(this.getSuccessUser(result.data)); + } + } + // token was not third party token, rethrow error from authenticateBearerToken + if (err instanceof HttpException) { + return this.error(err); + } + } + } + + throw new UnauthorizedException(`ApiAuthStrategy - Invalid Bearer token`); } const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); @@ -83,8 +130,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") if (noAuthProvided) { throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`); } + throw new UnauthorizedException( - `ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${allowedMethods}` + `ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${ + allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method" + }` ); } catch (err) { if (err instanceof Error) { @@ -271,4 +321,55 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") return user; } + + async validateThirdPartyAccessToken( + token: string, + request: ApiAuthGuardRequest + ): Promise<{ success: true; data: UserWithProfile } | { success: false }> { + // Removed requiredScopes parameter + const encryptionKey = this.config.get("CALENDSO_ENCRYPTION_KEY"); + if (!encryptionKey) { + throw new InternalServerErrorException("CALENDSO_ENCRYPTION_KEY environment variable is not set."); + } + + let decodedToken: OAuthTokenPayload; + try { + decodedToken = jwt.verify(token, encryptionKey) as OAuthTokenPayload; + } catch (e) { + return { success: false }; + } + + if (!decodedToken || decodedToken.token_type !== "Access Token") { + return { success: false }; + } + + let user: UserWithProfile | null = null; + let organizationId: number | null = null; + + if (decodedToken.userId) { + user = await this.userRepository.findByIdWithProfile(decodedToken.userId); + if (user) { + organizationId = this.usersService.getUserMainOrgId(user) as number; + } + } else if (decodedToken.teamId) { + const teamOwner = await this.userRepository.findOwnerByTeamIdWithProfile(decodedToken.teamId); + if (!teamOwner) { + throw new UnauthorizedException( + "ApiAuthStrategy - third-party token - No owner found for the associated team." + ); + } + user = teamOwner; + organizationId = + teamOwner.profiles?.find((p) => p.organizationId === decodedToken.teamId)?.organizationId ?? null; + } + + if (!user) { + throw new UnauthorizedException( + "ApiAuthStrategy - third-party token - No user or team owner associated with the token." + ); + } + + request.organizationId = organizationId; + return { success: true, data: user }; + } } diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 81a033c7f1..ce05add22f 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -85,6 +85,27 @@ export class UsersRepository { }); } + async findOwnerByTeamIdWithProfile(teamId: number): Promise { + return this.dbRead.prisma.user.findFirst({ + where: { + teams: { + some: { + teamId, + role: "OWNER", + }, + }, + }, + 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 findByIdsWithEventTypes(userIds: number[]) { return this.dbRead.prisma.user.findMany({ where: {