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
This commit is contained in:
Morgan
2025-07-09 22:47:07 +03:00
committed by GitHub
parent 2b44d09191
commit db84343581
4 changed files with 137 additions and 9 deletions
@@ -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",
}
@@ -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<AllowedAuthMethod[]>();
@@ -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<string>("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<string>("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<string>("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 };
}
}
@@ -85,6 +85,27 @@ export class UsersRepository {
});
}
async findOwnerByTeamIdWithProfile(teamId: number): Promise<UserWithProfile | null> {
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: {