teableio/teable

View on GitHub
apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts

Summary

Maintainability
A
0 mins
Test Coverage
import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
  NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { getRandomString } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import {
  PluginStatus,
  type IPluginGetTokenRo,
  type IPluginGetTokenVo,
  type IPluginRefreshTokenRo,
  type IPluginRefreshTokenVo,
} from '@teable/openapi';
import { CacheService } from '../../cache/cache.service';
import { second } from '../../utils/second';
import { AccessTokenService } from '../access-token/access-token.service';
import { validateSecret } from './utils';

interface IRefreshPayload {
  pluginId: string;
  secret: string;
  accessTokenId: string;
}

@Injectable()
export class PluginAuthService {
  accessTokenExpireIn = second('10m');
  refreshTokenExpireIn = second('30d');

  constructor(
    private readonly prismaService: PrismaService,
    private readonly cacheService: CacheService,
    private readonly accessTokenService: AccessTokenService,
    private readonly jwtService: JwtService
  ) {}

  private generateAccessToken({
    userId,
    scopes,
    clientId,
    name,
    baseId,
  }: {
    userId: string;
    scopes: string[];
    clientId: string;
    name: string;
    baseId: string;
  }) {
    return this.accessTokenService.createAccessToken({
      clientId,
      name: `plugin:${name}`,
      scopes,
      userId,
      baseIds: [baseId],
      // 10 minutes
      expiredTime: new Date(Date.now() + this.accessTokenExpireIn * 1000).toISOString(),
    });
  }

  private async generateRefreshToken({ pluginId, secret, accessTokenId }: IRefreshPayload) {
    return this.jwtService.signAsync(
      {
        secret,
        accessTokenId,
        pluginId,
      },
      { expiresIn: this.refreshTokenExpireIn }
    );
  }

  private async validateSecret(secret: string, pluginId: string) {
    const plugin = await this.prismaService.plugin
      .findFirstOrThrow({
        where: { id: pluginId, status: PluginStatus.Published },
      })
      .catch(() => {
        throw new NotFoundException('Plugin not found');
      });
    if (!plugin.pluginUser) {
      throw new BadRequestException('Plugin user not found');
    }
    const checkSecret = await validateSecret(secret, plugin.secret);
    if (!checkSecret) {
      throw new BadRequestException('Invalid secret');
    }
    return {
      ...plugin,
      pluginUser: plugin.pluginUser,
    };
  }

  async token(pluginId: string, ro: IPluginGetTokenRo): Promise<IPluginGetTokenVo> {
    const { secret, scopes, baseId } = ro;
    const plugin = await this.validateSecret(secret, pluginId);

    const accessToken = await this.generateAccessToken({
      userId: plugin.pluginUser,
      scopes,
      baseId,
      clientId: pluginId,
      name: plugin.name,
    });

    const refreshToken = await this.generateRefreshToken({
      pluginId,
      secret,
      accessTokenId: accessToken.id,
    });

    return {
      accessToken: accessToken.token,
      refreshToken,
      scopes,
      expiresIn: this.accessTokenExpireIn,
      refreshExpiresIn: this.refreshTokenExpireIn,
    };
  }

  async refreshToken(pluginId: string, ro: IPluginRefreshTokenRo): Promise<IPluginRefreshTokenVo> {
    const { secret, refreshToken } = ro;
    const plugin = await this.validateSecret(secret, pluginId);
    const payload = await this.jwtService.verifyAsync<IRefreshPayload>(refreshToken).catch(() => {
      // eslint-disable-next-line sonarjs/no-duplicate-string
      throw new BadRequestException('Invalid refresh token');
    });

    if (
      payload.pluginId !== pluginId ||
      payload.secret !== secret ||
      payload.accessTokenId === undefined
    ) {
      throw new BadRequestException('Invalid refresh token');
    }
    return this.prismaService.$tx(async (prisma) => {
      const oldAccessToken = await prisma.accessToken
        .findFirstOrThrow({
          where: { id: payload.accessTokenId },
        })
        .catch(() => {
          throw new BadRequestException('Invalid refresh token');
        });

      await prisma.accessToken.delete({
        where: { id: payload.accessTokenId, userId: plugin.pluginUser },
      });

      const baseId = oldAccessToken.baseIds ? JSON.parse(oldAccessToken.baseIds)[0] : '';
      const scopes = oldAccessToken.scopes ? JSON.parse(oldAccessToken.scopes) : [];
      if (!baseId) {
        throw new InternalServerErrorException('Anomalous token with no baseId');
      }

      const accessToken = await this.generateAccessToken({
        userId: plugin.pluginUser,
        scopes,
        baseId,
        clientId: pluginId,
        name: plugin.name,
      });

      const refreshToken = await this.generateRefreshToken({
        pluginId,
        secret,
        accessTokenId: accessToken.id,
      });
      return {
        accessToken: accessToken.token,
        refreshToken,
        scopes,
        expiresIn: this.accessTokenExpireIn,
        refreshExpiresIn: this.refreshTokenExpireIn,
      };
    });
  }

  async authCode(pluginId: string, baseId: string) {
    const count = await this.prismaService.pluginInstall.count({
      where: { pluginId, baseId },
    });
    if (count === 0) {
      throw new NotFoundException('Plugin not installed');
    }
    const authCode = getRandomString(16);
    await this.cacheService.set(`plugin:auth-code:${authCode}`, { baseId, pluginId }, second('5m'));
    return authCode;
  }
}