Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: oauth revoke #701

Merged
merged 6 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export class AccessTokenService {
async listAccessToken() {
const userId = this.cls.get('user.id');
const list = await this.prismaService.accessToken.findMany({
where: { userId, isOAuth: null },
where: { userId, clientId: null },
select: {
id: true,
name: true,
Expand All @@ -94,10 +94,10 @@ export class AccessTokenService {
}

async createAccessToken(
createAccessToken: CreateAccessTokenRo & { isOAuth?: boolean; userId?: string }
createAccessToken: CreateAccessTokenRo & { clientId?: string; userId?: string }
) {
const userId = createAccessToken.userId ?? this.cls.get('user.id')!;
const { name, description, scopes, spaceIds, baseIds, expiredTime, isOAuth } =
const { name, description, scopes, spaceIds, baseIds, expiredTime, clientId } =
createAccessToken;
const id = generateAccessTokenId();
const sign = getRandomString(16);
Expand All @@ -111,7 +111,7 @@ export class AccessTokenService {
baseIds: baseIds === null ? null : JSON.stringify(baseIds),
userId,
sign,
isOAuth,
clientId,
expiredTime: new Date(expiredTime).toISOString(),
},
select: {
Expand Down
10 changes: 5 additions & 5 deletions apps/nestjs-backend/src/features/auth/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class PermissionService {
return collaborator.roleName as BaseRole;
}

async getOauthAccessBy(userId: string) {
async getOAuthAccessBy(userId: string) {
const collaborator = await this.prismaService.txClient().collaborator.findMany({
where: {
userId,
Expand All @@ -77,16 +77,16 @@ export class PermissionService {
scopes: stringifyScopes,
spaceIds,
baseIds,
isOAuth,
clientId,
userId,
} = await this.prismaService.accessToken.findFirstOrThrow({
where: { id: accessTokenId },
select: { scopes: true, spaceIds: true, baseIds: true, isOAuth: true, userId: true },
select: { scopes: true, spaceIds: true, baseIds: true, clientId: true, userId: true },
});
const scopes = JSON.parse(stringifyScopes) as PermissionAction[];
if (isOAuth) {
if (clientId) {
const { spaceIds: spaceIdsByOAuth, baseIds: baseIdsByOAuth } =
await this.getOauthAccessBy(userId);
await this.getOAuthAccessBy(userId);
return {
scopes,
spaceIds: spaceIdsByOAuth,
Expand Down
22 changes: 12 additions & 10 deletions apps/nestjs-backend/src/features/oauth/oauth-server.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,12 @@ describe('OAuthServerService', () => {

expect(cacheService.get).toHaveBeenCalledWith(`oauth:code:${mockCode}`);
expect(cacheService.del).toHaveBeenCalledWith(`oauth:code:${mockCode}`);
expect(service['generateAccessToken']).toHaveBeenCalledWith(
mockCodeState.user.id,
mockCodeState.scopes,
mockClient.name
);
expect(service['generateAccessToken']).toHaveBeenCalledWith({
userId: mockCodeState.user.id,
scopes: mockCodeState.scopes,
clientId: mockClient.clientId,
clientName: mockClient.name,
});
expect(service['getRefreshToken']).toHaveBeenCalledWith(
mockClient,
mockAccessToken.id,
Expand Down Expand Up @@ -326,11 +327,12 @@ describe('OAuthServerService', () => {
expect(prismaService.txClient().accessToken.findUnique).toHaveBeenCalledWith({
where: { id: verifiedToken.accessTokenId },
});
expect(service['generateAccessToken']).toHaveBeenCalledWith(
oldAccessToken.userId,
['user|email_read'],
client.name
);
expect(service['generateAccessToken']).toHaveBeenCalledWith({
clientId: client.clientId,
clientName: client.name,
userId: oldAccessToken.userId,
scopes: ['user|email_read'],
});
expect(prismaService.txClient().oAuthAppToken.update).toHaveBeenCalledWith({
where: { refreshTokenSign: verifiedToken.sign, appSecretId: client.secretId },
data: {
Expand Down
32 changes: 24 additions & 8 deletions apps/nestjs-backend/src/features/oauth/oauth-server.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,19 @@ export class OAuthServerService {
done(null, code);
};

private generateAccessToken(userId: string, scopes: string[], clientName: string) {
private generateAccessToken({
userId,
scopes,
clientId,
clientName,
}: {
userId: string;
scopes: string[];
clientId: string;
clientName: string;
}) {
return this.accessTokenService.createAccessToken({
isOAuth: true,
clientId,
name: `oauth:${clientName}`,
scopes,
userId,
Expand Down Expand Up @@ -307,11 +317,12 @@ export class OAuthServerService {
}

// save access token
const accessToken = await this.generateAccessToken(
codeState.user.id,
codeState.scopes,
client.name
);
const accessToken = await this.generateAccessToken({
userId: codeState.user.id,
scopes: codeState.scopes,
clientId: client.clientId,
clientName: client.name,
});

// save oauth access token
const refreshTokenSign = getRandomString(16);
Expand Down Expand Up @@ -364,7 +375,12 @@ export class OAuthServerService {
return done(new UnauthorizedException('Invalid access token'));
}
const scopes = oldAccessToken.scopes ? JSON.parse(oldAccessToken.scopes) : [];
const accessToken = await this.generateAccessToken(oldAccessToken.userId, scopes, name);
const accessToken = await this.generateAccessToken({
userId: oldAccessToken.userId,
scopes,
clientId,
clientName: name,
});

// validate refresh_token and refresh refresh_token
const oauthAppToken = await this.prismaService
Expand Down
42 changes: 40 additions & 2 deletions apps/nestjs-backend/src/features/oauth/oauth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Put,
} from '@nestjs/common';
import {
OAuthCreateRo,
OAuthUpdateRo,
oauthCreateRoSchema,
oauthUpdateRoSchema,
} from '@teable/openapi';
import type {
AuthorizedVo,
GenerateOAuthSecretVo,
OAuthCreateVo,
OAuthGetListVo,
OAuthGetVo,
} from '@teable/openapi';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../types/cls';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { TokenAccess } from '../auth/decorators/token.decorator';
import { OAuthService } from './oauth.service';

@Controller('/api/oauth/client')
export class OAuthController {
constructor(private readonly oauthService: OAuthService) {}
constructor(
private readonly oauthService: OAuthService,
private readonly cls: ClsService<IClsStore>
) {}

@Get(':clientId')
async getOAuth(@Param('clientId') clientId: string): Promise<OAuthGetVo> {
Expand Down Expand Up @@ -60,4 +77,25 @@ export class OAuthController {
): Promise<void> {
return this.oauthService.deleteSecret(clientId, secretId);
}

@Post(':clientId/revoke-access')
@HttpCode(200)
async revokeToken(@Param('clientId') clientId: string) {
return this.oauthService.revokeAccess(clientId);
}

@Get(':clientId/revoke-token')
@TokenAccess()
async revokeTokenGet(@Param('clientId') clientId: string) {
const accessTokenId = this.cls.get('accessTokenId');
if (!accessTokenId) {
throw new BadRequestException('only access token request can use this endpoint');
}
return this.oauthService.revokeAccess(clientId);
}

@Get('authorized/list')
async getAuthorizedList(): Promise<AuthorizedVo[]> {
return this.oauthService.getAuthorizedList();
}
}
118 changes: 116 additions & 2 deletions apps/nestjs-backend/src/features/oauth/oauth.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { generateClientId, getRandomString, nullsToUndefined } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { Prisma, PrismaService } from '@teable/db-main-prisma';
import type {
AuthorizedVo,
GenerateOAuthSecretVo,
OAuthCreateRo,
OAuthCreateVo,
Expand Down Expand Up @@ -197,4 +198,117 @@ export class OAuthService {
},
});
}

async revokeAccess(clientId: string) {
// validate clientId is match with current user
const currentUserId = this.cls.get('user.id');
const app = await this.prismaService.oAuthApp.findFirst({
where: { clientId, createdBy: currentUserId },
});
if (!app) {
throw new ForbiddenException('No permission to revoke access: ' + clientId);
}
await this.prismaService.$tx(async () => {
await this.prismaService.txClient().oAuthAppAuthorized.deleteMany({
where: { clientId },
});
const secrets = await this.prismaService.txClient().oAuthAppSecret.findMany({
where: { clientId },
});
const secretIds = secrets.map((s) => s.id);
await this.prismaService.txClient().oAuthAppToken.deleteMany({
where: { appSecretId: { in: secretIds } },
});
// delete access token
await this.prismaService.txClient().accessToken.deleteMany({
where: { clientId },
});
});
}

async getAuthorizedList(): Promise<AuthorizedVo[]> {
const userId = this.cls.get('user.id');
const authorized = await this.prismaService.oAuthAppAuthorized.findMany({
where: {
userId,
},
select: {
clientId: true,
},
});
if (authorized.length === 0) {
return [];
}
const clientIds = authorized.map((a) => a.clientId);
const client = await this.prismaService.oAuthApp.findMany({
where: {
clientId: { in: clientIds },
},
});
if (client.length === 0) {
return [];
}
// user map
const users = await this.prismaService.user.findMany({
where: {
id: { in: client.map((c) => c.createdBy) },
},
select: {
id: true,
email: true,
name: true,
},
});
const userMap = users.reduce(
(acc, u) => {
acc[u.id] = {
email: u.email,
name: u.name,
};
return acc;
},
{} as Record<string, { email: string; name: string }>
);

// last used time
const lastUsedTime = await this.prismaService.$queryRaw<
{
clientId: string;
lastUsedTime: string;
}[]
>(Prisma.sql`
WITH ranked_clients AS (
SELECT
client_id,
last_used_time,
ROW_NUMBER() OVER (PARTITION BY client_id ORDER BY last_used_time DESC) AS rn
FROM oauth_app_secret
WHERE client_id IN (${Prisma.join(clientIds)})
)
SELECT client_id as clientId, last_used_time as lastUsedTime
FROM ranked_clients
WHERE rn = 1;
`);

const lastUsedTimeMap = lastUsedTime.reduce(
(acc, d) => {
acc[d.clientId] = d;
return acc;
},
{} as Record<string, { clientId: string; lastUsedTime: string }>
);

return client.map((c) =>
this.convertToVo({
clientId: c.clientId,
name: c.name,
description: c.description,
logo: c.logo,
homepage: c.homepage,
scopes: c.scopes,
lastUsedTime: lastUsedTimeMap[c.clientId]?.lastUsedTime,
createdUser: userMap[c.createdBy],
})
);
}
}
Loading