diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 402fd5874..c5cb52663 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -24,7 +24,6 @@ export const getJwtRefreshLifetime = async () => (await client.getSecret("JWT_RE export const getJwtServiceSecret = async () => (await client.getSecret("JWT_SERVICE_SECRET")).secretValue; // TODO: deprecate (related to ST V1) export const getJwtSignupLifetime = async () => (await client.getSecret("JWT_SIGNUP_LIFETIME")).secretValue || "15m"; export const getJwtProviderAuthLifetime = async () => (await client.getSecret("JWT_PROVIDER_AUTH_LIFETIME")).secretValue || "15m"; -export const getJwtServiceTokenSecret = async () => (await client.getSecret("JWT_SERVICE_TOKEN_SECRET")).secretValue; export const getMongoURL = async () => (await client.getSecret("MONGO_URL")).secretValue; export const getNodeEnv = async () => (await client.getSecret("NODE_ENV")).secretValue || "production"; export const getVerboseErrorOutput = async () => (await client.getSecret("VERBOSE_ERROR_OUTPUT")).secretValue === "true" && true; diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index e426eef5a..374c4652c 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -24,10 +24,18 @@ import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/auth"; declare module "jsonwebtoken" { + export interface AuthnJwtPayload extends jwt.JwtPayload { + authTokenType: AuthTokenType; + } export interface UserIDJwtPayload extends jwt.JwtPayload { userId: string; refreshVersion?: number; } + export interface ServiceRefreshTokenJwtPayload extends jwt.JwtPayload { + serviceTokenDataId: string; + authTokenType: string; + tokenVersion: number; + } } /** diff --git a/backend/src/ee/controllers/v3/serviceTokenDataController.ts b/backend/src/ee/controllers/v3/serviceTokenDataController.ts index d233cb0d9..4590f3ab8 100644 --- a/backend/src/ee/controllers/v3/serviceTokenDataController.ts +++ b/backend/src/ee/controllers/v3/serviceTokenDataController.ts @@ -1,3 +1,4 @@ +import jwt from "jsonwebtoken"; import { Request, Response } from "express"; import { Types } from "mongoose"; import { @@ -24,10 +25,11 @@ import { getUserProjectPermissions } from "../../services/ProjectRoleService"; import { ForbiddenError } from "@casl/ability"; -import { BadRequestError, ResourceNotFoundError } from "../../../utils/errors"; +import { BadRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; import { EEAuditLogService, EELicenseService } from "../../services"; -import { getJwtServiceTokenSecret } from "../../../config"; +import { getAuthSecret } from "../../../config"; +import { AuthTokenType } from "../../../variables"; /** * Return project key for service token V3 @@ -56,6 +58,99 @@ export const getServiceTokenDataKey = async (req: Request, res: Response) => { }); } +/** + * Return access and refresh token as per refresh operation + * @param req + * @param res + */ + export const refreshToken = async (req: Request, res: Response) => { + const { + body: { + refresh_token + } + } = await validateRequest(reqValidator.RefreshTokenV3, req); + + const decodedToken = ( + jwt.verify(refresh_token, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.SERVICE_REFRESH_TOKEN) throw UnauthorizedRequestError(); + + let serviceTokenData = await ServiceTokenDataV3.findOne({ + _id: new Types.ObjectId(decodedToken.serviceTokenDataId), + isActive: true + }); + + if (!serviceTokenData) throw UnauthorizedRequestError(); + + if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) { + // raise alarm + throw UnauthorizedRequestError(); + } + + const response: { + refresh_token?: string; + access_token: string; + expires_in: number; + token_type: string; + } = { + refresh_token, + access_token: "", + expires_in: 0, + token_type: "Bearer" + }; + + if (serviceTokenData.isRefreshTokenRotationEnabled) { + serviceTokenData = await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenData._id, + { + $inc: { + tokenVersion: 1 + } + }, + { + new: true + } + ); + + if (!serviceTokenData) throw BadRequestError(); + + response.refresh_token = createToken({ + payload: { + serviceTokenDataId: serviceTokenData._id.toString(), + authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN, + tokenVersion: serviceTokenData.tokenVersion + }, + secret: await getAuthSecret() + }); + } + + response.access_token = createToken({ + payload: { + serviceTokenDataId: serviceTokenData._id.toString(), + authTokenType: AuthTokenType.SERVICE_ACCESS_TOKEN, + tokenVersion: serviceTokenData.tokenVersion + }, + expiresIn: serviceTokenData.accessTokenTTL, + secret: await getAuthSecret() + }); + + response.expires_in = serviceTokenData.accessTokenTTL; + + await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenData._id, + { + refreshTokenLastUsed: new Date(), + $inc: { refreshTokenUsageCount: 1 } + }, + { + new: true + } + ); + + return res.status(200).send(response); +} + /** * Create service token data V3 * @param req @@ -71,8 +166,10 @@ export const createServiceTokenData = async (req: Request, res: Response) => { scopes, trustedIps, expiresIn, + accessTokenTTL, + isRefreshTokenRotationEnabled, encryptedKey, // for ServiceTokenDataV3Key - nonce // for ServiceTokenDataV3Key + nonce, // for ServiceTokenDataV3Key } } = await validateRequest(reqValidator.CreateServiceTokenV3, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -118,11 +215,15 @@ export const createServiceTokenData = async (req: Request, res: Response) => { user, workspace: new Types.ObjectId(workspaceId), publicKey, - usageCount: 0, + refreshTokenUsageCount: 0, + accessTokenUsageCount: 0, + tokenVersion: 1, trustedIps: reformattedTrustedIps, scopes, isActive, - expiresAt + expiresAt, + accessTokenTTL, + isRefreshTokenRotationEnabled }).save(); await new ServiceTokenDataV3Key({ @@ -133,18 +234,19 @@ export const createServiceTokenData = async (req: Request, res: Response) => { workspace: new Types.ObjectId(workspaceId) }).save(); - const token = createToken({ + const refreshToken = createToken({ payload: { - _id: serviceTokenData._id.toString() + serviceTokenDataId: serviceTokenData._id.toString(), + authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN, + tokenVersion: serviceTokenData.tokenVersion }, - expiresIn, - secret: await getJwtServiceTokenSecret() + secret: await getAuthSecret() }); await EEAuditLogService.createAuditLog( req.authData, { - type: EventType.CREATE_SERVICE_TOKEN_V3, + type: EventType.CREATE_SERVICE_TOKEN_V3, // TODO: update metadata: { name, isActive, @@ -160,7 +262,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => { return res.status(200).send({ serviceTokenData, - serviceToken: `stv3.${token}` + refreshToken }); } @@ -178,7 +280,9 @@ export const updateServiceTokenData = async (req: Request, res: Response) => { isActive, scopes, trustedIps, - expiresIn + expiresIn, + accessTokenTTL, + isRefreshTokenRotationEnabled } } = await validateRequest(reqValidator.UpdateServiceTokenV3, req); @@ -233,13 +337,15 @@ export const updateServiceTokenData = async (req: Request, res: Response) => { isActive, scopes, trustedIps: reformattedTrustedIps, - expiresAt + expiresAt, + accessTokenTTL, + isRefreshTokenRotationEnabled }, { new: true } ); - + if (!serviceTokenData) throw BadRequestError({ message: "Failed to update service token" }); diff --git a/backend/src/ee/routes/v3/serviceTokenData.ts b/backend/src/ee/routes/v3/serviceTokenData.ts index 2f421c7ec..ba1d04854 100644 --- a/backend/src/ee/routes/v3/serviceTokenData.ts +++ b/backend/src/ee/routes/v3/serviceTokenData.ts @@ -7,11 +7,16 @@ import { serviceTokenDataController } from "../../controllers/v3"; router.get( "/me/key", requireAuth({ - acceptedAuthModes: [AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.SERVICE_ACCESS_TOKEN] }), serviceTokenDataController.getServiceTokenDataKey ); +router.post( + "/me/token", + serviceTokenDataController.refreshToken +); + router.post( "/", requireAuth({ diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index a8b13fd82..14024bd03 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -1,381 +1,13 @@ -import { Request } from "express"; import { Types } from "mongoose"; import jwt from "jsonwebtoken"; -import bcrypt from "bcrypt"; -import { - APIKeyData, - APIKeyDataV2, - ITokenVersion, - IUser, - ServiceTokenData, - ServiceTokenDataV3, - TokenVersion, - User, -} from "../models"; -import { - APIKeyDataNotFoundError, - AccountNotFoundError, - BadRequestError, - ServiceTokenDataNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { ITokenVersion, TokenVersion } from "../models"; +import { UnauthorizedRequestError } from "../utils/errors"; import { getAuthSecret, getJwtAuthLifetime, - getJwtRefreshLifetime, - getJwtServiceTokenSecret + getJwtRefreshLifetime } from "../config"; -import { - AuthMode, - AuthTokenType -} from "../variables"; -import { - ServiceTokenAuthData, - ServiceTokenV3AuthData, - UserAuthData -} from "../interfaces/middleware"; - -import { ActorType } from "../ee/models"; -import { getUserAgentType } from "../utils/posthog"; - -/** - * - * @param {Object} obj - * @param {Object} obj.headers - HTTP request headers object - */ -export const validateAuthMode = ({ - headers, - acceptedAuthModes, -}: { - headers: { [key: string]: string | string[] | undefined }, - acceptedAuthModes: AuthMode[] -}) => { - - const apiKey = headers["x-api-key"]; - const authHeader = headers["authorization"]; - - let authMode, authTokenValue; - if (apiKey === undefined && authHeader === undefined) { - // case: no auth or X-API-KEY header present - throw BadRequestError({ message: "Missing Authorization or X-API-KEY in request header." }); - } - - if (typeof apiKey === "string") { - // case: treat request authentication type as via X-API-KEY (i.e. API Key) - authMode = AuthMode.API_KEY; - authTokenValue = apiKey; - } - - if (typeof authHeader === "string") { - // case: treat request authentication type as via Authorization header (i.e. either JWT or service token) - const [tokenType, tokenValue] = <[string, string]>authHeader.split(" ", 2) ?? [null, null] - - if (tokenType === null) - throw BadRequestError({ message: "Missing Authorization Header in the request header." }); - if (tokenType.toLowerCase() !== "bearer") - throw BadRequestError({ message: `The provided authentication type '${tokenType}' is not supported.` }); - if (tokenValue === null) - throw BadRequestError({ message: "Missing Authorization Body in the request header." }); - - const parts = tokenValue.split("."); - - switch (parts[0]) { - case "st": - authMode = AuthMode.SERVICE_TOKEN; - authTokenValue = tokenValue; - break; - case "stv3": - authMode = AuthMode.SERVICE_TOKEN_V3; - authTokenValue = parts.slice(1).join("."); - break; - default: - authMode = AuthMode.JWT; - authTokenValue = tokenValue; - } - } - - if (!authMode || !authTokenValue) throw BadRequestError({ message: "Missing valid Authorization or X-API-KEY in request header." }); - - if (!acceptedAuthModes.includes(authMode)) throw BadRequestError({ message: "The provided authentication type is not supported." }); - - return ({ - authMode, - authTokenValue, - }); -} - -/** - * Return user payload corresponding to JWT token [authTokenValue] - * that is either for browser / CLI or API Key - * @param {Object} obj - * @param {String} obj.authTokenValue - JWT token value - * @returns {User} user - user corresponding to JWT token - */ -export const getAuthUserPayload = async ({ - req, - authTokenValue, -}: { - req: Request, - authTokenValue: string; -}): Promise => { - const decodedToken = ( - jwt.verify(authTokenValue, await getAuthSecret()) - ); - - if ( - decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN && - decodedToken.authTokenType !== AuthTokenType.API_KEY - ) { - throw UnauthorizedRequestError(); - } - - if (decodedToken.authTokenType === AuthTokenType.ACCESS_TOKEN) { - const tokenVersion = await TokenVersion.findOneAndUpdate({ - _id: new Types.ObjectId(decodedToken.tokenVersionId), - user: decodedToken.userId - }, { - lastUsed: new Date(), - }); - - if (!tokenVersion) throw UnauthorizedRequestError(); - - if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError(); - } else if (decodedToken.authTokenType === AuthTokenType.API_KEY) { - const apiKeyData = await APIKeyDataV2.findOneAndUpdate( - { - _id: new Types.ObjectId(decodedToken.apiKeyDataId), - user: new Types.ObjectId(decodedToken.userId) - }, - { - lastUsed: new Date(), - $inc: { usageCount: 1 } - }, - { - new: true - } - ); - - if (!apiKeyData) throw UnauthorizedRequestError(); - } - - const user = await User.findOne({ - _id: new Types.ObjectId(decodedToken.userId), - }).select("+publicKey +accessVersion"); - - if (!user) throw AccountNotFoundError({ message: "Failed to find user" }); - - if (!user?.publicKey) throw UnauthorizedRequestError({ message: "Failed to authenticate user with partially set up account" }); - - return { - actor: { - type: ActorType.USER, - metadata: { - userId: user._id.toString(), - email: user.email - } - }, - authPayload: user, - ipAddress: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - userAgentType: getUserAgentType(req.headers["user-agent"]) - } -} - -/** - * Return service token data payload corresponding to service token [authTokenValue] - * @param {Object} obj - * @param {String} obj.authTokenValue - service token value - * @returns {ServiceTokenData} serviceTokenData - service token data - */ -export const getAuthSTDPayload = async ({ - req, - authTokenValue, -}: { - req: Request, - authTokenValue: string; -}): Promise => { - const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3); - - const serviceTokenData = await ServiceTokenData - .findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt") - - if (!serviceTokenData) { - throw ServiceTokenDataNotFoundError({ message: "Failed to find service token data" }); - } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { - // case: service token expired - await ServiceTokenData.findByIdAndDelete(serviceTokenData._id); - throw UnauthorizedRequestError({ - message: "Failed to authenticate expired service token", - }); - } - - const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash); - if (!isMatch) throw UnauthorizedRequestError({ - message: "Failed to authenticate service token", - }); - - const serviceTokenDataToReturn = await ServiceTokenData - .findOneAndUpdate({ - _id: new Types.ObjectId(TOKEN_IDENTIFIER), - }, { - lastUsed: new Date(), - }, { - new: true, - }) - .select("+encryptedKey +iv +tag") - - if (!serviceTokenDataToReturn) throw ServiceTokenDataNotFoundError({ message: "Failed to find service token data" }); - - return { - actor: { - type: ActorType.SERVICE, - metadata: { - serviceId: serviceTokenDataToReturn._id.toString(), - name: serviceTokenDataToReturn.name - } - }, - authPayload: serviceTokenDataToReturn, - ipAddress: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - userAgentType: getUserAgentType(req.headers["user-agent"]) - } -} - -/** - * Return service token data V3 payload corresponding to service token [authTokenValue] - * @param {Object} obj - * @param {String} obj.authTokenValue - service token value - * @returns {ServiceTokenData} serviceTokenData - service token data - */ - export const getAuthSTDV3Payload = async ({ - req, - authTokenValue, -}: { - req: Request, - authTokenValue: string; -}): Promise => { - const decodedToken = ( - jwt.verify(authTokenValue, await getJwtServiceTokenSecret()) - ); - - const serviceTokenData = await ServiceTokenDataV3.findOneAndUpdate( - { - _id: new Types.ObjectId(decodedToken._id), - isActive: true - }, - { - lastUsed: new Date(), - $inc: { usageCount: 1 } - }, - { - new: true - } - ); - - if (!serviceTokenData) { - throw UnauthorizedRequestError({ - message: "Failed to authenticate" - }); - } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { - // case: service token expired - await ServiceTokenDataV3.findByIdAndUpdate( - serviceTokenData._id, - { - isActive: false - }, - { - new: true - } - ); - - throw UnauthorizedRequestError({ - message: "Failed to authenticate", - }); - } - - return { - actor: { - type: ActorType.SERVICE_V3, - metadata: { - serviceId: serviceTokenData._id.toString(), - name: serviceTokenData.name - } - }, - authPayload: serviceTokenData, - ipAddress: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - userAgentType: getUserAgentType(req.headers["user-agent"]) - } -} - -/** - * Return API key data payload corresponding to API key [authTokenValue] - * @param {Object} obj - * @param {String} obj.authTokenValue - API key value - * @returns {APIKeyData} apiKeyData - API key data - */ -export const getAuthAPIKeyPayload = async ({ - req, - authTokenValue, -}: { - req: Request, - authTokenValue: string; -}): Promise => { - const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3); - - let apiKeyData = await APIKeyData - .findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt") - .populate<{ user: IUser }>("user", "+publicKey"); - - if (!apiKeyData) { - throw APIKeyDataNotFoundError({ message: "Failed to find API key data" }); - } else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) { - // case: API key expired - await APIKeyData.findByIdAndDelete(apiKeyData._id); - throw UnauthorizedRequestError({ - message: "Failed to authenticate expired API key", - }); - } - - const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash); - if (!isMatch) throw UnauthorizedRequestError({ - message: "Failed to authenticate API key", - }); - - apiKeyData = await APIKeyData.findOneAndUpdate({ - _id: new Types.ObjectId(TOKEN_IDENTIFIER), - }, { - lastUsed: new Date(), - }, { - new: true, - }); - - if (!apiKeyData) { - throw APIKeyDataNotFoundError({ message: "Failed to find API key data" }); - } - - const user = await User.findById(apiKeyData.user).select("+publicKey"); - - if (!user) { - throw AccountNotFoundError({ - message: "Failed to find user", - }); - } - - return { - actor: { - type: ActorType.USER, - metadata: { - userId: user._id.toString(), - email: user.email - } - }, - authPayload: user, - ipAddress: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - userAgentType: getUserAgentType(req.headers["user-agent"]) - } -} +import { AuthTokenType } from "../variables"; /** * Return newly issued (JWT) auth and refresh tokens to user with id [userId] diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 4b38079c1..f6252d5c6 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -49,7 +49,7 @@ import { import { TelemetryService } from "../services"; import { client, getEncryptionKey, getRootEncryptionKey } from "../config"; import { EEAuditLogService, EELogService, EESecretService } from "../ee/services"; -import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/auth"; +import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/authn/helpers"; import { getFolderByPath, getFolderIdFromServiceToken } from "../services/FolderService"; import picomatch from "picomatch"; import path from "path"; diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index aa74b0dc7..929d22fea 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -1,14 +1,9 @@ import jwt from "jsonwebtoken"; import { NextFunction, Request, Response } from "express"; -import { - getAuthAPIKeyPayload, - getAuthSTDPayload, - getAuthSTDV3Payload, - getAuthUserPayload, - validateAuthMode, -} from "../helpers/auth"; import { AuthMode } from "../variables"; import { AuthData } from "../interfaces/middleware"; +import { extractAuthMode, getAuthData } from "../utils/authn/helpers"; +import { UnauthorizedRequestError } from "../utils/errors"; declare module "jsonwebtoken" { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -32,42 +27,39 @@ const requireAuth = ({ acceptedAuthModes: AuthMode[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { + + // extract auth mode + const { authMode, authTokenValue } = await extractAuthMode({ + headers: req.headers + }); - // validate auth token against accepted auth modes [acceptedAuthModes] - // and return token type [authTokenType] and value [authTokenValue] - const { authMode, authTokenValue } = validateAuthMode({ - headers: req.headers, - acceptedAuthModes, + // validate auth mode + if (!acceptedAuthModes.includes(authMode)) throw UnauthorizedRequestError({ + message: "Failed to authenticate unaccepted authentication mode" }); - let authData: AuthData; + // get auth data / payload + const authData: AuthData = await getAuthData({ + authMode, + authTokenValue, + ipAddress: req.realIP, + userAgent: req.headers["user-agent"] ?? "" + }); switch (authMode) { case AuthMode.SERVICE_TOKEN: - authData = await getAuthSTDPayload({ - req, - authTokenValue, - }); req.serviceTokenData = authData.authPayload; break; - case AuthMode.SERVICE_TOKEN_V3: - authData = await getAuthSTDV3Payload({ - req, - authTokenValue - }); + case AuthMode.SERVICE_ACCESS_TOKEN: + req.serviceTokenData = authData.authPayload; break; case AuthMode.API_KEY: - authData = await getAuthAPIKeyPayload({ - req, - authTokenValue - }); + req.user = authData.authPayload; + break; + case AuthMode.API_KEY_V2: req.user = authData.authPayload; break; case AuthMode.JWT: - authData = await getAuthUserPayload({ - req, - authTokenValue - }); req.user = authData.authPayload; break; } diff --git a/backend/src/models/serviceTokenDataV3.ts b/backend/src/models/serviceTokenDataV3.ts index c9895402f..198b60507 100644 --- a/backend/src/models/serviceTokenDataV3.ts +++ b/backend/src/models/serviceTokenDataV3.ts @@ -25,9 +25,14 @@ export interface IServiceTokenDataV3 extends Document { user: Types.ObjectId; publicKey: string; isActive: boolean; - lastUsed?: Date; - usageCount: number; + refreshTokenLastUsed?: Date; + accessTokenLastUsed?: Date; + refreshTokenUsageCount: number; + accessTokenUsageCount: number; + tokenVersion: number; + isRefreshTokenRotationEnabled: boolean; expiresAt?: Date; + accessTokenTTL: number; scopes: Array; trustedIps: Array; } @@ -57,19 +62,43 @@ const serviceTokenDataV3Schema = new Schema( default: true, required: true }, - lastUsed: { + refreshTokenLastUsed: { type: Date, required: false }, - usageCount: { + accessTokenLastUsed: { + type: Date, + required: false + }, + refreshTokenUsageCount: { type: Number, default: 0, required: true }, - expiresAt: { + accessTokenUsageCount: { + type: Number, + default: 0, + required: true + }, + tokenVersion: { + type: Number, + default: 1, + required: true + }, + isRefreshTokenRotationEnabled: { + type: Boolean, + default: false, + required: true + }, + expiresAt: { // consider revising field name type: Date, required: false, - expires: 0 + // expires: 0 + }, + accessTokenTTL: { // seconds + type: Number, + default: 7200, + required: true }, scopes: { type: [ diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 960094f87..81e0cb4c0 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -7,7 +7,7 @@ import { AuthMode } from "../../variables"; router.get( "/raw", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), secretsController.getSecretsRaw ); @@ -15,7 +15,7 @@ router.get( router.get( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -29,7 +29,7 @@ router.get( router.post( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -43,7 +43,7 @@ router.post( router.patch( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -57,7 +57,7 @@ router.patch( router.delete( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -71,7 +71,7 @@ router.delete( router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -83,7 +83,7 @@ router.get( router.post( "/batch", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -94,7 +94,7 @@ router.post( router.patch( "/batch", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -105,7 +105,7 @@ router.patch( router.delete( "/batch", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -116,7 +116,7 @@ router.delete( router.post( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -127,7 +127,7 @@ router.post( router.get( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -138,7 +138,7 @@ router.get( router.patch( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -149,7 +149,7 @@ router.patch( router.delete( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts deleted file mode 100644 index c1c0e6ac5..000000000 --- a/backend/src/utils/auth.ts +++ /dev/null @@ -1,435 +0,0 @@ -import express from "express"; -import passport from "passport"; -import { Types } from "mongoose"; -import { AuthData } from "../interfaces/middleware"; -import { - AuthMethod, - MembershipOrg, - Organization, - ServiceAccount, - ServiceTokenData, - ServiceTokenDataV3, - User -} from "../models"; -import { createToken } from "../helpers/auth"; -import { - getAuthSecret, - getClientIdGitHubLogin, - getClientIdGitLabLogin, - getClientIdGoogleLogin, - getClientSecretGitHubLogin, - getClientSecretGitLabLogin, - getClientSecretGoogleLogin, - getJwtProviderAuthLifetime, - getSiteURL, - getUrlGitLabLogin -} from "../config"; -import { getSSOConfigHelper } from "../ee/helpers/organizations"; -import { InternalServerError, OrganizationNotFoundError } from "./errors"; -import { ACCEPTED, AuthTokenType, INTEGRATION_GITHUB_API_URL, INVITED, MEMBER } from "../variables"; -import { standardRequest } from "../config/request"; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const GoogleStrategy = require("passport-google-oauth20").Strategy; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const GitHubStrategy = require("passport-github").Strategy; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const GitLabStrategy = require("passport-gitlab2").Strategy; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { MultiSamlStrategy } = require("@node-saml/passport-saml"); - -/** - * Returns an object containing the id of the authentication data payload - * @param {AuthData} authData - authentication data object - * @returns - */ -const getAuthDataPayloadIdObj = (authData: AuthData) => { - if (authData.authPayload instanceof User) { - return { userId: authData.authPayload._id }; - } - - if (authData.authPayload instanceof ServiceAccount) { - return { serviceAccountId: authData.authPayload._id }; - } - - if (authData.authPayload instanceof ServiceTokenData) { - return { serviceTokenDataId: authData.authPayload._id }; - } - - if (authData.authPayload instanceof ServiceTokenDataV3) { - return { serviceTokenDataId: authData.authPayload._id }; - } -}; - -/** - * Returns an object containing the user associated with the authentication data payload - * @param {AuthData} authData - authentication data object - * @returns - */ -const getAuthDataPayloadUserObj = (authData: AuthData) => { - if (authData.authPayload instanceof User) { - return { user: authData.authPayload._id }; - } - - if (authData.authPayload instanceof ServiceAccount) { - return { user: authData.authPayload.user }; - } - - if (authData.authPayload instanceof ServiceTokenData) { - return { user: authData.authPayload.user }; - } - - if (authData.authPayload instanceof ServiceTokenDataV3) { - return { user: authData.authPayload.user }; - } -} - -const initializePassport = async () => { - const clientIdGoogleLogin = await getClientIdGoogleLogin(); - const clientSecretGoogleLogin = await getClientSecretGoogleLogin(); - const clientIdGitHubLogin = await getClientIdGitHubLogin(); - const clientSecretGitHubLogin = await getClientSecretGitHubLogin(); - const urlGitLab = await getUrlGitLabLogin(); - const clientIdGitLabLogin = await getClientIdGitLabLogin(); - const clientSecretGitLabLogin = await getClientSecretGitLabLogin(); - - if (clientIdGoogleLogin && clientSecretGoogleLogin) { - passport.use(new GoogleStrategy({ - passReqToCallback: true, - clientID: clientIdGoogleLogin, - clientSecret: clientSecretGoogleLogin, - callbackURL: "/api/v1/sso/google", - scope: ["profile", " email"], - }, async ( - req: express.Request, - accessToken: string, - refreshToken: string, - profile: any, - done: any - ) => { - try { - const email = profile.emails[0].value; - - let user = await User.findOne({ - email - }).select("+publicKey"); - - if (!user) { - user = await new User({ - email, - authMethods: [AuthMethod.GOOGLE], - firstName: profile.name.givenName, - lastName: profile.name.familyName - }).save(); - } - - let isLinkingRequired = false; - if (!user.authMethods.includes(AuthMethod.GOOGLE)) { - isLinkingRequired = true; - } - - const isUserCompleted = !!user.publicKey; - const providerAuthToken = createToken({ - payload: { - authTokenType: AuthTokenType.PROVIDER_TOKEN, - userId: user._id.toString(), - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - authMethod: AuthMethod.GOOGLE, - isUserCompleted, - isLinkingRequired, - ...(req.query.state ? { - callbackPort: req.query.state as string - } : {}) - }, - expiresIn: await getJwtProviderAuthLifetime(), - secret: await getAuthSecret(), - }); - - req.isUserCompleted = isUserCompleted; - req.providerAuthToken = providerAuthToken; - done(null, profile); - } catch (err) { - done(null, false); - } - })); - } - - if (clientIdGitHubLogin && clientSecretGitHubLogin) { - passport.use(new GitHubStrategy({ - passReqToCallback: true, - clientID: clientIdGitHubLogin, - clientSecret: clientSecretGitHubLogin, - callbackURL: "/api/v1/sso/github", - scope: ["user:email"] - }, - async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => { - interface GitHubEmail { - email: string; - primary: boolean; - verified: boolean; - visibility: null | string; - } - - const { data }: { data: GitHubEmail[] } = await standardRequest.get( - `${INTEGRATION_GITHUB_API_URL}/user/emails`, - { - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - - const primaryEmail = data.filter((gitHubEmail: GitHubEmail) => gitHubEmail.primary)[0]; - const email = primaryEmail.email; - - let user = await User.findOne({ - email - }).select("+publicKey"); - - if (!user) { - user = await new User({ - email: email, - authMethods: [AuthMethod.GITHUB], - firstName: profile.displayName, - lastName: "" - }).save(); - } - - let isLinkingRequired = false; - if (!user.authMethods.includes(AuthMethod.GITHUB)) { - isLinkingRequired = true; - } - - const isUserCompleted = !!user.publicKey; - const providerAuthToken = createToken({ - payload: { - authTokenType: AuthTokenType.PROVIDER_TOKEN, - userId: user._id.toString(), - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - authMethod: AuthMethod.GITHUB, - isUserCompleted, - isLinkingRequired, - ...(req.query.state ? { - callbackPort: req.query.state as string - } : {}) - }, - expiresIn: await getJwtProviderAuthLifetime(), - secret: await getAuthSecret(), - }); - - req.isUserCompleted = isUserCompleted; - req.providerAuthToken = providerAuthToken; - return done(null, profile); - } - )); - } - - if (urlGitLab && clientIdGitLabLogin && clientSecretGitLabLogin) { - passport.use(new GitLabStrategy({ - passReqToCallback: true, - clientID: clientIdGitLabLogin, - clientSecret: clientSecretGitLabLogin, - callbackURL: "/api/v1/sso/gitlab", - baseURL: urlGitLab - }, - async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => { - const email = profile.emails[0].value; - - let user = await User.findOne({ - email - }).select("+publicKey"); - - if (!user) { - user = await new User({ - email: email, - authMethods: [AuthMethod.GITLAB], - firstName: profile.displayName, - lastName: "" - }).save(); - } - - let isLinkingRequired = false; - if (!user.authMethods.includes(AuthMethod.GITLAB)) { - isLinkingRequired = true; - } - - const isUserCompleted = !!user.publicKey; - const providerAuthToken = createToken({ - payload: { - authTokenType: AuthTokenType.PROVIDER_TOKEN, - userId: user._id.toString(), - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - authMethod: AuthMethod.GITLAB, - isUserCompleted, - isLinkingRequired, - ...(req.query.state ? { - callbackPort: req.query.state as string - } : {}) - }, - expiresIn: await getJwtProviderAuthLifetime(), - secret: await getAuthSecret(), - }); - - req.isUserCompleted = isUserCompleted; - req.providerAuthToken = providerAuthToken; - return done(null, profile); - } - )); - } - - passport.use("saml", new MultiSamlStrategy( - { - passReqToCallback: true, - getSamlOptions: async (req: any, done: any) => { - const { ssoIdentifier } = req.params; - - const ssoConfig = await getSSOConfigHelper({ - ssoConfigId: new Types.ObjectId(ssoIdentifier) - }); - - interface ISAMLConfig { - callbackUrl: string; - entryPoint: string; - issuer: string; - cert: string; - audience: string; - wantAuthnResponseSigned?: boolean; - } - - const samlConfig: ISAMLConfig = ({ - callbackUrl: `${await getSiteURL()}/api/v1/sso/saml2/${ssoIdentifier}`, - entryPoint: ssoConfig.entryPoint, - issuer: ssoConfig.issuer, - cert: ssoConfig.cert, - audience: await getSiteURL() - }); - - if (ssoConfig.authProvider.toString() === AuthMethod.JUMPCLOUD_SAML.toString()) { - samlConfig.wantAuthnResponseSigned = false; - } - - if (ssoConfig.authProvider.toString() === AuthMethod.AZURE_SAML.toString()) { - if (req.body.RelayState && JSON.parse(req.body.RelayState).spInitiated) { - samlConfig.audience = `spn:${ssoConfig.issuer}`; - } - } - - req.ssoConfig = ssoConfig; - - done(null, samlConfig); - }, - }, - async (req: any, profile: any, done: any) => { - if (!req.ssoConfig.isActive) return done(InternalServerError()); - - const organization = await Organization.findById(req.ssoConfig.organization); - - if (!organization) return done(OrganizationNotFoundError()); - - const email = profile.email; - const firstName = profile.firstName; - const lastName = profile.lastName; - - let user = await User.findOne({ - email - }).select("+publicKey"); - - if (user) { - // if user does not have SAML enabled then update - const hasSamlEnabled = user.authMethods - .some( - (authMethod: AuthMethod) => [ - AuthMethod.OKTA_SAML, - AuthMethod.AZURE_SAML, - AuthMethod.JUMPCLOUD_SAML - ].includes(authMethod) - ); - - if (!hasSamlEnabled) { - await User.findByIdAndUpdate( - user._id, - { - authMethods: [req.ssoConfig.authProvider] - }, - { - new: true - } - ); - } - - let membershipOrg = await MembershipOrg.findOne( - { - user: user._id, - organization: organization._id - } - ); - - if (!membershipOrg) { - membershipOrg = await new MembershipOrg({ - inviteEmail: email, - user: user._id, - organization: organization._id, - role: MEMBER, - status: ACCEPTED - }).save(); - } - - if (membershipOrg.status === INVITED) { - membershipOrg.status = ACCEPTED; - await membershipOrg.save(); - } - } else { - user = await new User({ - email, - authMethods: [req.ssoConfig.authProvider], - firstName, - lastName - }).save(); - - await new MembershipOrg({ - inviteEmail: email, - user: user._id, - organization: organization._id, - role: MEMBER, - status: INVITED - }).save(); - } - - const isUserCompleted = !!user.publicKey; - const providerAuthToken = createToken({ - payload: { - authTokenType: AuthTokenType.PROVIDER_TOKEN, - userId: user._id.toString(), - email: user.email, - firstName, - lastName, - organizationName: organization?.name, - authMethod: req.ssoConfig.authProvider, - isUserCompleted, - ...(req.body.RelayState ? { - callbackPort: JSON.parse(req.body.RelayState).callbackPort as string - } : {}) - }, - expiresIn: await getJwtProviderAuthLifetime(), - secret: await getAuthSecret(), - }); - - req.isUserCompleted = isUserCompleted; - req.providerAuthToken = providerAuthToken; - - done(null, profile); - } - )); -} - -export { - getAuthDataPayloadIdObj, - getAuthDataPayloadUserObj, - initializePassport, -} diff --git a/backend/src/utils/authn/authModeValidators/apiKey.ts b/backend/src/utils/authn/authModeValidators/apiKey.ts new file mode 100644 index 000000000..a1bfd28b8 --- /dev/null +++ b/backend/src/utils/authn/authModeValidators/apiKey.ts @@ -0,0 +1,50 @@ +import { Types } from "mongoose"; +import { + APIKeyData, + IUser, + User +} from "../../../models"; +import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors"; +import bcrypt from "bcrypt"; + +interface ValidateAPIKeyParams { + authTokenValue: string; +} + +export const validateAPIKey = async ({ + authTokenValue +}: ValidateAPIKeyParams) => { + + const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3); + + let apiKeyData = await APIKeyData + .findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt") + .populate<{ user: IUser }>("user", "+publicKey"); + + if (!apiKeyData) { + throw UnauthorizedRequestError(); + } else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) { + // case: API key expired + await APIKeyData.findByIdAndDelete(apiKeyData._id); + throw UnauthorizedRequestError(); + } + + const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash); + if (!isMatch) throw UnauthorizedRequestError(); + + apiKeyData = await APIKeyData.findOneAndUpdate({ + _id: new Types.ObjectId(TOKEN_IDENTIFIER), + }, { + lastUsed: new Date(), + }, { + new: true, + }); + + if (!apiKeyData) throw UnauthorizedRequestError(); + + const user = await User.findById(apiKeyData.user).select("+publicKey"); + + if (!user) throw AccountNotFoundError(); + + return user; +} \ No newline at end of file diff --git a/backend/src/utils/authn/authModeValidators/apiKeyV2.ts b/backend/src/utils/authn/authModeValidators/apiKeyV2.ts new file mode 100644 index 000000000..2b57542b9 --- /dev/null +++ b/backend/src/utils/authn/authModeValidators/apiKeyV2.ts @@ -0,0 +1,39 @@ +import jwt from "jsonwebtoken"; +import { APIKeyDataV2, User } from "../../../models"; +import { getAuthSecret } from "../../../config"; +import { AuthTokenType } from "../../../variables"; +import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors"; + +interface ValidateAPIKeyV2Params { + authTokenValue: string; +} + +export const validateAPIKeyV2 = async ({ + authTokenValue +}: ValidateAPIKeyV2Params) => { + + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.API_KEY) throw UnauthorizedRequestError(); + + const apiKeyData = await APIKeyDataV2.findByIdAndUpdate( + decodedToken.apiKeyDataId, + { + lastUsed: new Date(), + $inc: { usageCount: 1 } + }, + { + new: true + } + ); + + if (!apiKeyData) throw UnauthorizedRequestError(); + + const user = await User.findById(apiKeyData.user).select("+publicKey"); + + if (!user) throw AccountNotFoundError(); + + return user; +} diff --git a/backend/src/utils/authn/authModeValidators/index.ts b/backend/src/utils/authn/authModeValidators/index.ts new file mode 100644 index 000000000..0ac4c3c1c --- /dev/null +++ b/backend/src/utils/authn/authModeValidators/index.ts @@ -0,0 +1,5 @@ +export * from "./apiKey"; +export * from "./apiKeyV2"; +export * from "./jwt"; +export * from "./serviceTokenV2"; +export * from "./serviceTokenV3"; \ No newline at end of file diff --git a/backend/src/utils/authn/authModeValidators/jwt.ts b/backend/src/utils/authn/authModeValidators/jwt.ts new file mode 100644 index 000000000..f9f0971fc --- /dev/null +++ b/backend/src/utils/authn/authModeValidators/jwt.ts @@ -0,0 +1,41 @@ +import jwt from "jsonwebtoken"; +import { Types } from "mongoose"; +import { TokenVersion, User } from "../../../models"; +import { getAuthSecret } from "../../../config"; +import { AuthTokenType } from "../../../variables"; +import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors"; + +interface ValidateJWTParams { + authTokenValue: string; +} + +export const validateJWT = async ({ + authTokenValue +}: ValidateJWTParams) => { + + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN) throw UnauthorizedRequestError(); + + const tokenVersion = await TokenVersion.findOneAndUpdate({ + _id: new Types.ObjectId(decodedToken.tokenVersionId), + user: decodedToken.userId + }, { + lastUsed: new Date(), + }); + + if (!tokenVersion) throw UnauthorizedRequestError(); + if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError(); + + const user = await User.findOne({ + _id: new Types.ObjectId(decodedToken.userId), + }).select("+publicKey"); + + if (!user) throw AccountNotFoundError({ message: "Failed to find user" }); + + if (!user?.publicKey) throw UnauthorizedRequestError({ message: "Failed to authenticate user with partially set up account" }); + + return user; +} diff --git a/backend/src/utils/authn/authModeValidators/serviceTokenV2.ts b/backend/src/utils/authn/authModeValidators/serviceTokenV2.ts new file mode 100644 index 000000000..0ebed5963 --- /dev/null +++ b/backend/src/utils/authn/authModeValidators/serviceTokenV2.ts @@ -0,0 +1,44 @@ +import { Types } from "mongoose"; +import { ServiceTokenData } from "../../../models"; +import { ResourceNotFoundError, UnauthorizedRequestError } from "../../errors"; +import bcrypt from "bcrypt"; + +interface ValidateServiceTokenV2Params { + authTokenValue: string; +} + +export const validateServiceTokenV2 = async ({ + authTokenValue +}: ValidateServiceTokenV2Params) => { + const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3); + + const serviceTokenData = await ServiceTokenData + .findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt") + + if (!serviceTokenData) { + throw UnauthorizedRequestError(); + } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { + // case: service token expired + await ServiceTokenData.findByIdAndDelete(serviceTokenData._id); + throw UnauthorizedRequestError({ + message: "Failed to authenticate expired service token", + }); + } + + const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash); + if (!isMatch) throw UnauthorizedRequestError(); + + const serviceTokenDataToReturn = await ServiceTokenData + .findOneAndUpdate({ + _id: new Types.ObjectId(TOKEN_IDENTIFIER), + }, { + lastUsed: new Date(), + }, { + new: true, + }) + .select("+encryptedKey +iv +tag") + + if (!serviceTokenDataToReturn) throw ResourceNotFoundError(); + + return serviceTokenDataToReturn; +} \ No newline at end of file diff --git a/backend/src/utils/authn/authModeValidators/serviceTokenV3.ts b/backend/src/utils/authn/authModeValidators/serviceTokenV3.ts new file mode 100644 index 000000000..330f60c1c --- /dev/null +++ b/backend/src/utils/authn/authModeValidators/serviceTokenV3.ts @@ -0,0 +1,64 @@ +import jwt from "jsonwebtoken"; +import { Types } from "mongoose"; +import { ServiceTokenDataV3 } from "../../../models"; +import { getAuthSecret } from "../../../config"; +import { AuthTokenType } from "../../../variables"; +import { UnauthorizedRequestError } from "../../errors"; + +interface ValidateServiceTokenV3Params { + authTokenValue: string; +} + +export const validateServiceTokenV3 = async ({ + authTokenValue +}: ValidateServiceTokenV3Params) => { + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.SERVICE_ACCESS_TOKEN) throw UnauthorizedRequestError(); + + const serviceTokenData = await ServiceTokenDataV3.findOne({ + _id: new Types.ObjectId(decodedToken.serviceTokenDataId), + isActive: true + }); + + if (!serviceTokenData) { + throw UnauthorizedRequestError({ + message: "Failed to authenticate" + }); + } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { + // case: service token expired + await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenData._id, + { + isActive: false + }, + { + new: true + } + ); + + throw UnauthorizedRequestError({ + message: "Failed to authenticate", + }); + } else if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) { + // TODO: raise alarm + throw UnauthorizedRequestError({ + message: "Failed to authenticate", + }); + } + + await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenData._id, + { + accessTokenLastUsed: new Date(), + $inc: { accessTokenUsageCount: 1 } + }, + { + new: true + } + ); + + return serviceTokenData; +} \ No newline at end of file diff --git a/backend/src/utils/authn/helpers/authDataExtractors.ts b/backend/src/utils/authn/helpers/authDataExtractors.ts new file mode 100644 index 000000000..60639739e --- /dev/null +++ b/backend/src/utils/authn/helpers/authDataExtractors.ts @@ -0,0 +1,53 @@ +import { AuthData } from "../../../interfaces/middleware"; +import { + ServiceAccount, + ServiceTokenData, + ServiceTokenDataV3, + User +} from "../../../models"; + +/** + * Returns an object containing the id of the authentication data payload + * @param {AuthData} authData - authentication data object + * @returns + */ + export const getAuthDataPayloadIdObj = (authData: AuthData) => { + if (authData.authPayload instanceof User) { + return { userId: authData.authPayload._id }; + } + + if (authData.authPayload instanceof ServiceAccount) { + return { serviceAccountId: authData.authPayload._id }; + } + + if (authData.authPayload instanceof ServiceTokenData) { + return { serviceTokenDataId: authData.authPayload._id }; + } + + if (authData.authPayload instanceof ServiceTokenDataV3) { + return { serviceTokenDataId: authData.authPayload._id }; + } +}; + +/** + * Returns an object containing the user associated with the authentication data payload + * @param {AuthData} authData - authentication data object + * @returns + */ +export const getAuthDataPayloadUserObj = (authData: AuthData) => { + if (authData.authPayload instanceof User) { + return { user: authData.authPayload._id }; + } + + if (authData.authPayload instanceof ServiceAccount) { + return { user: authData.authPayload.user }; + } + + if (authData.authPayload instanceof ServiceTokenData) { + return { user: authData.authPayload.user }; + } + + if (authData.authPayload instanceof ServiceTokenDataV3) { + return { user: authData.authPayload.user }; + } +} \ No newline at end of file diff --git a/backend/src/utils/authn/helpers/index.ts b/backend/src/utils/authn/helpers/index.ts new file mode 100644 index 000000000..064efe133 --- /dev/null +++ b/backend/src/utils/authn/helpers/index.ts @@ -0,0 +1,195 @@ +import { AuthData } from "../../../interfaces/middleware"; +import jwt from "jsonwebtoken"; +import { getAuthSecret } from "../../../config"; +import { ActorType } from "../../../ee/models"; +import { AuthMode, AuthTokenType } from "../../../variables"; +import { UnauthorizedRequestError } from "../../errors"; +import { + validateAPIKey, + validateAPIKeyV2, + validateJWT, + validateServiceTokenV2, + validateServiceTokenV3 +} from "../authModeValidators"; +import { getUserAgentType } from "../../posthog"; + +export * from "./authDataExtractors"; + +interface ExtractAuthModeParams { + headers: { [key: string]: string | string[] | undefined } +} + +interface ExtractAuthModeReturn { + authMode: AuthMode; + authTokenValue: string; +} + +interface GetAuthDataParams { + authMode: AuthMode; + authTokenValue: string; + ipAddress: string; + userAgent: string; +} + +/** + * Returns the recognized authentication mode based on token in [headers]; accepted token types include: + * - SERVICE_TOKEN + * - API_KEY + * - JWT + * - SERVICE_ACCESS_TOKEN (from ST V3) + * - API_KEY_V2 + * @param {Object} params + * @param {Object.} params.headers - The HTTP request headers, usually from Express's `req.headers`. + * @returns {Promise} The derived authentication mode based on the headers. + * @throws {UnauthorizedError} Throws an error if no applicable authMode is found. + */ +export const extractAuthMode = async ({ + headers +}: ExtractAuthModeParams): Promise => { + + const apiKey = headers["x-api-key"] as string; + const authHeader = headers["authorization"] as string; + + if (apiKey) { + return { authMode: AuthMode.API_KEY, authTokenValue: apiKey }; + } + + if (!authHeader) throw UnauthorizedRequestError({ + message: "Failed to authenticate unknown authentication method" + }); + + if (!authHeader.startsWith("Bearer ")) throw UnauthorizedRequestError({ + message: "Failed to authenticate unknown authentication method" + }); + + const authTokenValue = authHeader.slice(7); + + if (authTokenValue.startsWith("st.")) { + return { authMode: AuthMode.SERVICE_TOKEN, authTokenValue }; + } + + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + switch (decodedToken.authTokenType) { + case AuthTokenType.ACCESS_TOKEN: + return { authMode: AuthMode.JWT, authTokenValue }; + case AuthTokenType.API_KEY: + return { authMode: AuthMode.API_KEY_V2, authTokenValue }; + case AuthTokenType.SERVICE_ACCESS_TOKEN: + return { authMode: AuthMode.SERVICE_ACCESS_TOKEN, authTokenValue }; + default: + throw UnauthorizedRequestError({ + message: "Failed to authenticate unknown authentication method" + }); + } +} + +export const getAuthData = async ({ + authMode, + authTokenValue, + ipAddress, + userAgent +}: GetAuthDataParams): Promise => { + + const userAgentType = getUserAgentType(userAgent); + + switch (authMode) { + case AuthMode.SERVICE_TOKEN: { + const serviceTokenData = await validateServiceTokenV2({ + authTokenValue + }); + + return { + actor: { + type: ActorType.SERVICE, + metadata: { + serviceId: serviceTokenData._id.toString(), + name: serviceTokenData.name + } + }, + authPayload: serviceTokenData, + ipAddress, + userAgent, + userAgentType + } + } + case AuthMode.SERVICE_ACCESS_TOKEN: { + const serviceTokenData = await validateServiceTokenV3({ + authTokenValue + }); + + return { + actor: { + type: ActorType.SERVICE_V3, + metadata: { + serviceId: serviceTokenData._id.toString(), + name: serviceTokenData.name + } + }, + authPayload: serviceTokenData, + ipAddress, + userAgent, + userAgentType + } + } + case AuthMode.API_KEY: { + const user = await validateAPIKey({ + authTokenValue + }); + + return { + actor: { + type: ActorType.USER, + metadata: { + userId: user._id.toString(), + email: user.email + } + }, + authPayload: user, + ipAddress, + userAgent, + userAgentType + } + } + case AuthMode.API_KEY_V2: { + const user = await validateAPIKeyV2({ + authTokenValue + }); + + return { + actor: { + type: ActorType.USER, + metadata: { + userId: user._id.toString(), + email: user.email + } + }, + authPayload: user, + ipAddress, + userAgent, + userAgentType + } + } + case AuthMode.JWT: { + const user = await validateJWT({ + authTokenValue + }); + + return { + actor: { + type: ActorType.USER, + metadata: { + userId: user._id.toString(), + email: user.email + } + }, + authPayload: user, + ipAddress, + userAgent, + userAgentType + } + } + } +} \ No newline at end of file diff --git a/backend/src/utils/authn/passport/github.ts b/backend/src/utils/authn/passport/github.ts new file mode 100644 index 000000000..2f0a9b1ab --- /dev/null +++ b/backend/src/utils/authn/passport/github.ts @@ -0,0 +1,60 @@ +import express from "express"; +import passport from "passport"; +import { + getClientIdGitHubLogin, + getClientSecretGitHubLogin, +} from "../../../config"; +import { standardRequest } from "../../../config/request"; +import { AuthMethod } from "../../../models"; +import { INTEGRATION_GITHUB_API_URL } from "../../../variables"; +import { handleSSOUserTokenFlow } from "./helpers"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const GitHubStrategy = require("passport-github").Strategy; + +export const initializeGitHubStrategy = async () => { + const clientIdGitHubLogin = await getClientIdGitHubLogin(); + const clientSecretGitHubLogin = await getClientSecretGitHubLogin(); + if (clientIdGitHubLogin && clientSecretGitHubLogin) { + passport.use( + new GitHubStrategy({ + passReqToCallback: true, + clientID: clientIdGitHubLogin, + clientSecret: clientSecretGitHubLogin, + callbackURL: "/api/v1/sso/github", + scope: ["user:email"] + }, async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => { + interface GitHubEmail { + email: string; + primary: boolean; + verified: boolean; + visibility: null | string; + } + + const { data }: { data: GitHubEmail[] } = await standardRequest.get( + `${INTEGRATION_GITHUB_API_URL}/user/emails`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + const primaryEmail = data.filter((gitHubEmail: GitHubEmail) => gitHubEmail.primary)[0]; + const email = primaryEmail.email; + + const { isUserCompleted, providerAuthToken } = await handleSSOUserTokenFlow({ + email, + firstName: profile.displayName, + lastName: "", + authMethod: AuthMethod.GITHUB, + callbackPort: req.query.state as string + }); + + req.isUserCompleted = isUserCompleted; + req.providerAuthToken = providerAuthToken; + return done(null, profile); + }) + ); + } +} \ No newline at end of file diff --git a/backend/src/utils/authn/passport/gitlab.ts b/backend/src/utils/authn/passport/gitlab.ts new file mode 100644 index 000000000..22851a450 --- /dev/null +++ b/backend/src/utils/authn/passport/gitlab.ts @@ -0,0 +1,44 @@ +import express from "express"; +import passport from "passport"; +import { + getClientIdGitLabLogin, + getClientSecretGitLabLogin, + getUrlGitLabLogin +} from "../../../config"; +import { AuthMethod } from "../../../models"; +import { handleSSOUserTokenFlow } from "./helpers"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const GitLabStrategy = require("passport-gitlab2").Strategy; + +export const initializeGitLabStrategy = async () => { + const urlGitLab = await getUrlGitLabLogin(); + const clientIdGitLabLogin = await getClientIdGitLabLogin(); + const clientSecretGitLabLogin = await getClientSecretGitLabLogin(); + + if (urlGitLab && clientIdGitLabLogin && clientSecretGitLabLogin) { + passport.use( + new GitLabStrategy({ + passReqToCallback: true, + clientID: clientIdGitLabLogin, + clientSecret: clientSecretGitLabLogin, + callbackURL: "/api/v1/sso/gitlab", + baseURL: urlGitLab + }, async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => { + const email = profile.emails[0].value; + + const { isUserCompleted, providerAuthToken } = await handleSSOUserTokenFlow({ + email, + firstName: profile.displayName, + lastName: "", + authMethod: AuthMethod.GITLAB, + callbackPort: req.query.state as string + }); + + req.isUserCompleted = isUserCompleted; + req.providerAuthToken = providerAuthToken; + return done(null, profile); + }) + ); + } +} \ No newline at end of file diff --git a/backend/src/utils/authn/passport/google.ts b/backend/src/utils/authn/passport/google.ts new file mode 100644 index 000000000..126f2f9fb --- /dev/null +++ b/backend/src/utils/authn/passport/google.ts @@ -0,0 +1,48 @@ +import express from "express"; +import passport from "passport"; +import { getClientIdGoogleLogin, getClientSecretGoogleLogin } from "../../../config"; +import { AuthMethod } from "../../../models"; + +import { handleSSOUserTokenFlow } from "./helpers"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const GoogleStrategy = require("passport-google-oauth20").Strategy; + +export const initializeGoogleStrategy = async () => { + const clientIdGoogleLogin = await getClientIdGoogleLogin(); + const clientSecretGoogleLogin = await getClientSecretGoogleLogin(); + + if (clientIdGoogleLogin && clientSecretGoogleLogin) { + passport.use(new GoogleStrategy({ + passReqToCallback: true, + clientID: clientIdGoogleLogin, + clientSecret: clientSecretGoogleLogin, + callbackURL: "/api/v1/sso/google", + scope: ["profile", " email"], + }, async ( + req: express.Request, + accessToken: string, + refreshToken: string, + profile: any, + done: any + ) => { + try { + const email = profile.emails[0].value; + + const { isUserCompleted, providerAuthToken } = await handleSSOUserTokenFlow({ + email, + firstName: profile.name.givenName, + lastName: profile.name.familyName, + authMethod: AuthMethod.GOOGLE, + callbackPort: req.query.state as string + }); + + req.isUserCompleted = isUserCompleted; + req.providerAuthToken = providerAuthToken; + done(null, profile); + } catch (err) { + done(null, false); + } + })); + } +} \ No newline at end of file diff --git a/backend/src/utils/authn/passport/helpers.ts b/backend/src/utils/authn/passport/helpers.ts new file mode 100644 index 000000000..e8a21c37d --- /dev/null +++ b/backend/src/utils/authn/passport/helpers.ts @@ -0,0 +1,62 @@ +import { + AuthMethod, + User +} from "../../../models"; +import { createToken } from "../../../helpers/auth"; +import { AuthTokenType } from "../../../variables"; +import { getAuthSecret, getJwtProviderAuthLifetime} from "../../../config"; + +interface SSOUserTokenFlowParams { + email: string; + firstName: string; + lastName: string; + authMethod: AuthMethod; + callbackPort?: string; +} + +export const handleSSOUserTokenFlow = async ({ + email, + firstName, + lastName, + authMethod, + callbackPort +}: SSOUserTokenFlowParams) => { + let user = await User.findOne({ + email + }).select("+publicKey"); + + if (!user) { + user = await new User({ + email, + authMethods: [authMethod], + firstName, + lastName + }).save(); + } + + let isLinkingRequired = false; + if (!user.authMethods.includes(authMethod)) { + isLinkingRequired = true; + } + + const isUserCompleted = !!user.publicKey; + const providerAuthToken = createToken({ + payload: { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user._id.toString(), + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + authMethod, + isUserCompleted, + isLinkingRequired, + ...(callbackPort ? { + callbackPort + } : {}) + }, + expiresIn: await getJwtProviderAuthLifetime(), + secret: await getAuthSecret(), + }); + + return { isUserCompleted, providerAuthToken }; +} \ No newline at end of file diff --git a/backend/src/utils/authn/passport/index.ts b/backend/src/utils/authn/passport/index.ts new file mode 100644 index 000000000..4346c7d67 --- /dev/null +++ b/backend/src/utils/authn/passport/index.ts @@ -0,0 +1,4 @@ +export { initializeGoogleStrategy } from "./google"; +export { initializeGitHubStrategy } from "./github"; +export { initializeGitLabStrategy } from "./gitlab"; +export { initializeSamlStrategy } from "./saml"; diff --git a/backend/src/utils/authn/passport/saml.ts b/backend/src/utils/authn/passport/saml.ts new file mode 100644 index 000000000..f02e78be1 --- /dev/null +++ b/backend/src/utils/authn/passport/saml.ts @@ -0,0 +1,173 @@ +import passport from "passport"; +import { + getAuthSecret, + getJwtProviderAuthLifetime, + getSiteURL +} from "../../../config"; +import { + AuthMethod, + MembershipOrg, + Organization, + User +} from "../../../models"; +import { + createToken +} from "../../../helpers/auth"; +import { + ACCEPTED, + AuthTokenType, + INVITED, + MEMBER +} from "../../../variables"; +import { Types } from "mongoose"; +import { getSSOConfigHelper } from "../../../ee/helpers/organizations"; +import { InternalServerError, OrganizationNotFoundError } from "../../errors"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { MultiSamlStrategy } = require("@node-saml/passport-saml"); + +export const initializeSamlStrategy = async () => { + passport.use("saml", new MultiSamlStrategy( + { + passReqToCallback: true, + getSamlOptions: async (req: any, done: any) => { + const { ssoIdentifier } = req.params; + + const ssoConfig = await getSSOConfigHelper({ + ssoConfigId: new Types.ObjectId(ssoIdentifier) + }); + + interface ISAMLConfig { + callbackUrl: string; + entryPoint: string; + issuer: string; + cert: string; + audience: string; + wantAuthnResponseSigned?: boolean; + } + + const samlConfig: ISAMLConfig = ({ + callbackUrl: `${await getSiteURL()}/api/v1/sso/saml2/${ssoIdentifier}`, + entryPoint: ssoConfig.entryPoint, + issuer: ssoConfig.issuer, + cert: ssoConfig.cert, + audience: await getSiteURL() + }); + + if (ssoConfig.authProvider.toString() === AuthMethod.JUMPCLOUD_SAML.toString()) { + samlConfig.wantAuthnResponseSigned = false; + } + + if (ssoConfig.authProvider.toString() === AuthMethod.AZURE_SAML.toString()) { + if (req.body.RelayState && JSON.parse(req.body.RelayState).spInitiated) { + samlConfig.audience = `spn:${ssoConfig.issuer}`; + } + } + + req.ssoConfig = ssoConfig; + + done(null, samlConfig); + }, + }, + async (req: any, profile: any, done: any) => { + if (!req.ssoConfig.isActive) return done(InternalServerError()); + + const organization = await Organization.findById(req.ssoConfig.organization); + + if (!organization) return done(OrganizationNotFoundError()); + + const email = profile.email; + const firstName = profile.firstName; + const lastName = profile.lastName; + + let user = await User.findOne({ + email + }).select("+publicKey"); + + if (user) { + // if user does not have SAML enabled then update + const hasSamlEnabled = user.authMethods + .some( + (authMethod: AuthMethod) => [ + AuthMethod.OKTA_SAML, + AuthMethod.AZURE_SAML, + AuthMethod.JUMPCLOUD_SAML + ].includes(authMethod) + ); + + if (!hasSamlEnabled) { + await User.findByIdAndUpdate( + user._id, + { + authMethods: [req.ssoConfig.authProvider] + }, + { + new: true + } + ); + } + + let membershipOrg = await MembershipOrg.findOne( + { + user: user._id, + organization: organization._id + } + ); + + if (!membershipOrg) { + membershipOrg = await new MembershipOrg({ + inviteEmail: email, + user: user._id, + organization: organization._id, + role: MEMBER, + status: ACCEPTED + }).save(); + } + + if (membershipOrg.status === INVITED) { + membershipOrg.status = ACCEPTED; + await membershipOrg.save(); + } + } else { + user = await new User({ + email, + authMethods: [req.ssoConfig.authProvider], + firstName, + lastName + }).save(); + + await new MembershipOrg({ + inviteEmail: email, + user: user._id, + organization: organization._id, + role: MEMBER, + status: INVITED + }).save(); + } + + const isUserCompleted = !!user.publicKey; + const providerAuthToken = createToken({ + payload: { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user._id.toString(), + email: user.email, + firstName, + lastName, + organizationName: organization?.name, + authMethod: req.ssoConfig.authProvider, + isUserCompleted, + ...(req.body.RelayState ? { + callbackPort: JSON.parse(req.body.RelayState).callbackPort as string + } : {}) + }, + expiresIn: await getJwtProviderAuthLifetime(), + secret: await getAuthSecret(), + }); + + req.isUserCompleted = isUserCompleted; + req.providerAuthToken = providerAuthToken; + + done(null, profile); + } + )); +} \ No newline at end of file diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts index 62c7272f3..b9fe39992 100644 --- a/backend/src/utils/setup/index.ts +++ b/backend/src/utils/setup/index.ts @@ -26,7 +26,12 @@ import { reencryptSecretBlindIndexDataSalts } from "./reencryptData"; import { getMongoURL, getNodeEnv, getRedisUrl, getSentryDSN } from "../../config"; -import { initializePassport } from "../auth"; +import { + initializeGitHubStrategy, + initializeGitLabStrategy, + initializeGoogleStrategy, + initializeSamlStrategy +} from "../authn/passport"; import { logger } from "../logging"; /** @@ -55,7 +60,11 @@ export const setup = async () => { // initializing global feature set await EELicenseService.initGlobalFeatureSet(); - await initializePassport(); + // initializing auth strategies + await initializeGoogleStrategy(); + await initializeGitHubStrategy() + await initializeGitLabStrategy(); + await initializeSamlStrategy(); // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY // to base64 256-bit ROOT_ENCRYPTION_KEY diff --git a/backend/src/validation/serviceTokenDataV3.ts b/backend/src/validation/serviceTokenDataV3.ts index 976f1a1e5..4be60c645 100644 --- a/backend/src/validation/serviceTokenDataV3.ts +++ b/backend/src/validation/serviceTokenDataV3.ts @@ -60,6 +60,12 @@ import { checkIPAgainstBlocklist } from "../utils/ip"; } }; +export const RefreshTokenV3 = z.object({ + body: z.object({ + refresh_token: z.string().trim() + }) +}); + export const CreateServiceTokenV3 = z.object({ body: z.object({ name: z.string().trim(), @@ -80,8 +86,10 @@ export const CreateServiceTokenV3 = z.object({ .array() .min(1), expiresIn: z.number().optional(), + accessTokenTTL: z.number().int().min(1), encryptedKey: z.string().trim(), - nonce: z.string().trim() + nonce: z.string().trim(), + isRefreshTokenRotationEnabled: z.boolean().default(false) }) }); @@ -108,7 +116,9 @@ export const UpdateServiceTokenV3 = z.object({ .array() .min(1) .optional(), - expiresIn: z.number().optional() + expiresIn: z.number().optional(), + accessTokenTTL: z.number().int().min(1).optional(), + isRefreshTokenRotationEnabled: z.boolean().optional() }), }); diff --git a/backend/src/variables/authentication.ts b/backend/src/variables/authentication.ts index 30ba4bf14..bdad84ff6 100644 --- a/backend/src/variables/authentication.ts +++ b/backend/src/variables/authentication.ts @@ -1,17 +1,22 @@ +// TODO: merge [AuthTokenType] and [AuthMode] + export enum AuthTokenType { ACCESS_TOKEN = "accessToken", REFRESH_TOKEN = "refreshToken", - SIGNUP_TOKEN = "signupToken", - MFA_TOKEN = "mfaToken", - PROVIDER_TOKEN = "providerToken", - API_KEY = "apiKey" + SIGNUP_TOKEN = "signupToken", // TODO: remove in favor of claim + MFA_TOKEN = "mfaToken", // TODO: remove in favor of claim + PROVIDER_TOKEN = "providerToken", // TODO: remove in favor of claim + API_KEY = "apiKey", + SERVICE_ACCESS_TOKEN = "serviceAccessToken", + SERVICE_REFRESH_TOKEN = "serviceRefreshToken" } export enum AuthMode { JWT = "jwt", SERVICE_TOKEN = "serviceToken", - SERVICE_TOKEN_V3 = "serviceTokenV3", - API_KEY = "apiKey" + SERVICE_ACCESS_TOKEN = "serviceAccessToken", + API_KEY = "apiKey", + API_KEY_V2 = "apiKeyV2" } export const K8_USER_AGENT_NAME = "k8-operator" \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceTokens/queries.tsx b/frontend/src/hooks/api/serviceTokens/queries.tsx index 900bb1d34..44786ba0d 100644 --- a/frontend/src/hooks/api/serviceTokens/queries.tsx +++ b/frontend/src/hooks/api/serviceTokens/queries.tsx @@ -88,14 +88,18 @@ export const useUpdateServiceTokenV3 = () => { isActive, scopes, trustedIps, - expiresIn + expiresIn, + accessTokenTTL, + isRefreshTokenRotationEnabled }) => { const { data: { serviceTokenData } } = await apiRequest.patch(`/api/v3/service-token/${serviceTokenDataId}`, { name, isActive, scopes, trustedIps, - expiresIn + expiresIn, + accessTokenTTL, + isRefreshTokenRotationEnabled }); return serviceTokenData; diff --git a/frontend/src/hooks/api/serviceTokens/types.ts b/frontend/src/hooks/api/serviceTokens/types.ts index d47c3bc4c..18e59c68e 100644 --- a/frontend/src/hooks/api/serviceTokens/types.ts +++ b/frontend/src/hooks/api/serviceTokens/types.ts @@ -56,11 +56,15 @@ export type ServiceTokenDataV3 = { name: string; workspace: string; isActive: boolean; - lastUsed?: string; - usageCount: number; + refreshTokenLastUsed?: string; + accessTokenLastUsed?: string; + refreshTokenUsageCount: number; + accessTokenUsageCount: number; scopes: ServiceTokenV3Scope[]; trustedIps: ServiceTokenV3TrustedIp[]; expiresAt?: string; + accessTokenTTL: number; + isRefreshTokenRotationEnabled: boolean; createdAt: string; updatedAt: string; }; @@ -74,12 +78,14 @@ export type CreateServiceTokenDataV3DTO = { ipAddress: string; }[]; expiresIn?: number; + accessTokenTTL: number; encryptedKey: string; nonce: string; + isRefreshTokenRotationEnabled: boolean; } export type CreateServiceTokenDataV3Res = { - serviceToken: string; + refreshToken: string; serviceTokenData: ServiceTokenDataV3; } @@ -92,6 +98,8 @@ export type UpdateServiceTokenDataV3DTO = { ipAddress: string; }[]; expiresIn?: number; + accessTokenTTL?: number; + isRefreshTokenRotationEnabled?: boolean; } export type DeleteServiceTokenDataV3DTO = { diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx index c27e280a5..aba48b23e 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx @@ -1,5 +1,5 @@ -// import { APIKeyV2Section } from "../APIKeyV2Section"; import { APIKeySection } from "../APIKeySection"; +// import { APIKeyV2Section } from "../APIKeyV2Section"; export const PersonalAPIKeyTab = () => { return ( diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx index cc3c81bab..3d76b2435 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx @@ -1,8 +1,9 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { faPlus,faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faCopy,faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; +import { motion } from "framer-motion"; import nacl from "tweetnacl"; import { encodeBase64 } from "tweetnacl-util"; import * as yup from "yup"; @@ -21,12 +22,17 @@ import { ModalContent, Select, SelectItem, - UpgradePlanModal -} from "@app/components/v2"; + Switch, + Tab, + TabList, + TabPanel, + Tabs, + UpgradePlanModal} from "@app/components/v2"; import { useSubscription, useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; import { useCreateServiceTokenV3, useGetUserWsKey, @@ -41,8 +47,13 @@ import { } from "@app/hooks/api/serviceTokens/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; +enum TabSections { + General = "general", + Advanced = "advanced" +} + const expirations = [ - { label: "Never", value: undefined }, + { label: "Never", value: "" }, { label: "1 day", value: "86400" }, { label: "7 days", value: "604800" }, { label: "1 month", value: "2592000" }, @@ -60,6 +71,17 @@ const permissionsMap: { const schema = yup.object({ name: yup.string().required("ST V3 name is required"), expiresIn: yup.string(), + accessTokenTTL: yup + .string() + .test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => { + if (typeof value === "undefined") { + return false; + } + + const num = parseInt(value, 10); + return !Number.isNaN(num) && num > 0 && String(num) === value; + }) + .required("Access Token TTL is required"), scopes: yup .array( yup.object({ @@ -79,14 +101,15 @@ const schema = yup.object({ .required() .label("Scope"), trustedIps: yup - .array( - yup.object({ - ipAddress: yup.string().max(50).required().label("IP Address") - }) - ) - .min(1) - .required() - .label("Trusted IP") + .array( + yup.object({ + ipAddress: yup.string().max(50).required().label("IP Address") + }) + ) + .min(1) + .required() + .label("Trusted IP"), + isRefreshTokenRotationEnabled: yup.boolean().default(false) }).required(); export type FormData = yup.InferType; @@ -102,6 +125,9 @@ export const AddServiceTokenV3Modal = ({ handlePopUpOpen, handlePopUpToggle }: Props) => { + const [newServiceTokenJSON, setNewServiceTokenJSON] = useState(""); + const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false); + const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); @@ -118,6 +144,7 @@ export const AddServiceTokenV3Modal = ({ resolver: yupResolver(schema), defaultValues: { name: "", + accessTokenTTL: "7200", scopes: [{ permission: "read", environment: currentWorkspace?.environments?.[0]?.slug, @@ -128,6 +155,21 @@ export const AddServiceTokenV3Modal = ({ }] } }); + + useEffect(() => { + let timer: NodeJS.Timeout; + + if (isServiceTokenJSONCopied) { + timer = setTimeout(() => setIsServiceTokenJSONCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [setIsServiceTokenJSONCopied]); + + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(newServiceTokenJSON); + setIsServiceTokenJSONCopied.on(); + }; useEffect(() => { const serviceTokenData = popUp?.serviceTokenV3?.data as { @@ -135,6 +177,8 @@ export const AddServiceTokenV3Modal = ({ name: string; scopes: ServiceTokenV3Scope[]; trustedIps: ServiceTokenV3TrustedIp[]; + accessTokenTTL: number; + isRefreshTokenRotationEnabled: boolean; }; if (serviceTokenData) { @@ -163,11 +207,14 @@ export const AddServiceTokenV3Modal = ({ return ({ ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` }); - }) + }), + accessTokenTTL: String(serviceTokenData.accessTokenTTL), + isRefreshTokenRotationEnabled: serviceTokenData.isRefreshTokenRotationEnabled }); } else { reset({ name: "", + accessTokenTTL: "7200", scopes: [{ permission: "read", environment: currentWorkspace?.environments?.[0]?.slug, @@ -186,8 +233,10 @@ export const AddServiceTokenV3Modal = ({ const onFormSubmit = async ({ name, expiresIn, + accessTokenTTL, scopes, - trustedIps + trustedIps, + isRefreshTokenRotationEnabled }: FormData) => { try { const serviceTokenData = popUp?.serviceTokenV3?.data as { @@ -213,8 +262,12 @@ export const AddServiceTokenV3Modal = ({ name, scopes: reformattedScopes, trustedIps, - expiresIn: expiresIn === "" ? undefined : Number(expiresIn) + expiresIn: expiresIn === "" ? undefined : Number(expiresIn), + accessTokenTTL: Number(accessTokenTTL), + isRefreshTokenRotationEnabled }); + + handlePopUpToggle("serviceTokenV3", false); } else { // create if (!currentWorkspace?._id) return; @@ -239,24 +292,29 @@ export const AddServiceTokenV3Modal = ({ privateKey: localStorage.getItem("PRIVATE_KEY") as string }); - const { serviceToken } = await createMutateAsync({ + const { refreshToken } = await createMutateAsync({ name, workspaceId: currentWorkspace._id, publicKey, scopes: reformattedScopes, trustedIps, expiresIn: expiresIn === "" ? undefined : Number(expiresIn), + accessTokenTTL: Number(accessTokenTTL), encryptedKey: ciphertext, - nonce + nonce, + isRefreshTokenRotationEnabled }); const downloadData = { - publicKey, - privateKey, - serviceToken + public_key: publicKey, + private_key: privateKey, + refresh_token: refreshToken }; - const blob = new Blob([JSON.stringify(downloadData, null, 2)], { type: "application/json" }); + const serviceTokenJSON = JSON.stringify(downloadData, null, 2); + setNewServiceTokenJSON(serviceTokenJSON); + + const blob = new Blob([serviceTokenJSON], { type: "application/json" }); const href = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = href; @@ -272,7 +330,6 @@ export const AddServiceTokenV3Modal = ({ }); reset(); - handlePopUpToggle("serviceTokenV3", false); } catch (err) { console.error(err); createNotification({ @@ -281,6 +338,8 @@ export const AddServiceTokenV3Modal = ({ }); } } + + const hasServiceTokenJSON = Boolean(newServiceTokenJSON); return ( { handlePopUpToggle("serviceTokenV3", isOpen); reset(); + setNewServiceTokenJSON(""); }} > -
- ( - - - - )} - /> - {tokenScopes.map(({ id }, index) => ( -
- ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - remove(index)} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="p-3" + {!hasServiceTokenJSON ? ( + + + +
+ General + Advanced +
+
+ + - -
-
- ))} -
- -
- {tokenTrustedIps.map(({ id }, index) => ( -
- { - return ( - ( + + + + )} + /> + {tokenScopes.map(({ id }, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + remove(index)} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> + + + +
+ {tokenTrustedIps.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> +
+ ( + onChange(isChecked)} + isChecked={value} + > + Refresh Token Rotation + + )} /> - - ); - }} - /> - { - if (subscription?.ipAllowlisting) { - removeTrustedIp(index); - return; - } - - handlePopUpOpen("upgradePlan"); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="p-3" +

When enabled, as a result of exchanging a refresh token, a new refresh token will be issued and the existing token will be invalidated.

+
+
+
+ +
+ +
- ))} -
- + + + Click to copy + +
- ( - - - - )} - /> -
- - -
- + )} handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx index dd9268bf6..d41a0bd82 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx @@ -34,6 +34,8 @@ type Props = { name?: string; scopes?: ServiceTokenV3Scope[]; trustedIps?: ServiceTokenV3TrustedIp[]; + accessTokenTTL?: number; + isRefreshTokenRotationEnabled?: boolean; } ) => void; }; @@ -81,10 +83,9 @@ export const ServiceTokenV3Table = ({ Status Scopes Trusted IPs - {/* # Times Used */} - Last Used + Access Token TTL Created At - Expires At + Valid Until @@ -97,12 +98,12 @@ export const ServiceTokenV3Table = ({ _id, name, isActive, - lastUsed, - // usageCount, scopes, trustedIps, createdAt, - expiresAt + expiresAt, + accessTokenTTL, + isRefreshTokenRotationEnabled }) => { return ( @@ -160,8 +161,7 @@ export const ServiceTokenV3Table = ({ ); })} - {/* {usageCount} */} - {lastUsed ? format(new Date(lastUsed), "yyyy-MM-dd") : "-"} + {accessTokenTTL} {format(new Date(createdAt), "yyyy-MM-dd")} {expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"} @@ -176,7 +176,9 @@ export const ServiceTokenV3Table = ({ serviceTokenDataId: _id, name, scopes, - trustedIps + trustedIps, + accessTokenTTL, + isRefreshTokenRotationEnabled }); }} size="lg"