@ -16,6 +16,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
|||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
export enum AppTokenType {
|
export enum AppTokenType {
|
||||||
RefreshToken = 'REFRESH_TOKEN',
|
RefreshToken = 'REFRESH_TOKEN',
|
||||||
|
CodeChallenge = 'CODE_CHALLENGE',
|
||||||
|
AuthorizationCode = 'AUTHORIZATION_CODE',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ name: 'appToken', schema: 'core' })
|
@Entity({ name: 'appToken', schema: 'core' })
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { SignUpService } from 'src/engine/core-modules/auth/services/sign-up.ser
|
|||||||
import { GoogleGmailAuthController } from 'src/engine/core-modules/auth/controllers/google-gmail-auth.controller';
|
import { GoogleGmailAuthController } from 'src/engine/core-modules/auth/controllers/google-gmail-auth.controller';
|
||||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||||
|
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
|
||||||
|
|
||||||
import { AuthResolver } from './auth.resolver';
|
import { AuthResolver } from './auth.resolver';
|
||||||
|
|
||||||
@ -67,6 +68,7 @@ const jwtModule = JwtModule.registerAsync({
|
|||||||
JwtAuthStrategy,
|
JwtAuthStrategy,
|
||||||
AuthResolver,
|
AuthResolver,
|
||||||
GoogleAPIsService,
|
GoogleAPIsService,
|
||||||
|
AppTokenService,
|
||||||
],
|
],
|
||||||
exports: [jwtModule, TokenService],
|
exports: [jwtModule, TokenService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -137,9 +137,14 @@ export class AuthResolver {
|
|||||||
|
|
||||||
@Mutation(() => AuthorizeApp)
|
@Mutation(() => AuthorizeApp)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
authorizeApp(@Args() authorizeAppInput: AuthorizeAppInput): AuthorizeApp {
|
async authorizeApp(
|
||||||
const authorizedApp =
|
@Args() authorizeAppInput: AuthorizeAppInput,
|
||||||
this.authService.generateAuthorizationCode(authorizeAppInput);
|
@AuthUser() user: User,
|
||||||
|
): Promise<AuthorizeApp> {
|
||||||
|
const authorizedApp = await this.authService.generateAuthorizationCode(
|
||||||
|
authorizeAppInput,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
|
||||||
return authorizedApp;
|
return authorizedApp;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,21 @@
|
|||||||
import { Field, ArgsType } from '@nestjs/graphql';
|
import { Field, ArgsType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
@ArgsType()
|
@ArgsType()
|
||||||
export class AuthorizeAppInput {
|
export class AuthorizeAppInput {
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
|
||||||
@Field(() => String)
|
@Field(() => String, { nullable: true })
|
||||||
codeChallenge: string;
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
codeChallenge?: string;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
redirectUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,21 @@
|
|||||||
import { ArgsType, Field } from '@nestjs/graphql';
|
import { ArgsType, Field } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
@ArgsType()
|
@ArgsType()
|
||||||
export class ExchangeAuthCodeInput {
|
export class ExchangeAuthCodeInput {
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
authorizationCode: string;
|
authorizationCode: string;
|
||||||
|
|
||||||
@Field(() => String)
|
@Field(() => String, { nullable: true })
|
||||||
codeVerifier: string;
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
codeVerifier?: string;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientSecret?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
|||||||
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
|
||||||
import { EmailService } from 'src/engine/integrations/email/email.service';
|
import { EmailService } from 'src/engine/integrations/email/email.service';
|
||||||
import { SignUpService } from 'src/engine/core-modules/auth/services/sign-up.service';
|
import { SignUpService } from 'src/engine/core-modules/auth/services/sign-up.service';
|
||||||
|
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
@ -43,6 +44,10 @@ describe('AuthService', () => {
|
|||||||
provide: getRepositoryToken(User, 'core'),
|
provide: getRepositoryToken(User, 'core'),
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(AppToken, 'core'),
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: EnvironmentService,
|
provide: EnvironmentService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { render } from '@react-email/components';
|
import { render } from '@react-email/components';
|
||||||
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
|
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
|
||||||
|
import { addMilliseconds } from 'date-fns';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
|
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
|
||||||
import { assert } from 'src/utils/assert';
|
import { assert } from 'src/utils/assert';
|
||||||
@ -31,6 +33,10 @@ import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password
|
|||||||
import { SignUpService } from 'src/engine/core-modules/auth/services/sign-up.service';
|
import { SignUpService } from 'src/engine/core-modules/auth/services/sign-up.service';
|
||||||
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
|
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
|
||||||
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
|
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
|
||||||
|
import {
|
||||||
|
AppToken,
|
||||||
|
AppTokenType,
|
||||||
|
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||||
|
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
|
||||||
@ -52,6 +58,8 @@ export class AuthService {
|
|||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
|
@InjectRepository(AppToken, 'core')
|
||||||
|
private readonly appTokenRepository: Repository<AppToken>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async challenge(challengeInput: ChallengeInput) {
|
async challenge(challengeInput: ChallengeInput) {
|
||||||
@ -177,9 +185,10 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
generateAuthorizationCode(
|
async generateAuthorizationCode(
|
||||||
authorizeAppInput: AuthorizeAppInput,
|
authorizeAppInput: AuthorizeAppInput,
|
||||||
): AuthorizeApp {
|
user: User,
|
||||||
|
): Promise<AuthorizeApp> {
|
||||||
// TODO: replace with db call to - third party app table
|
// TODO: replace with db call to - third party app table
|
||||||
const apps = [
|
const apps = [
|
||||||
{
|
{
|
||||||
@ -191,7 +200,7 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { clientId } = authorizeAppInput;
|
const { clientId, codeChallenge } = authorizeAppInput;
|
||||||
|
|
||||||
const client = apps.find((app) => app.id === clientId);
|
const client = apps.find((app) => app.id === clientId);
|
||||||
|
|
||||||
@ -199,13 +208,48 @@ export class AuthService {
|
|||||||
throw new NotFoundException(`Invalid client '${clientId}'`);
|
throw new NotFoundException(`Invalid client '${clientId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!client.redirectUrl && !authorizeAppInput.redirectUrl) {
|
||||||
|
throw new NotFoundException(`redirectUrl not found for '${clientId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
const authorizationCode = crypto.randomBytes(42).toString('hex');
|
const authorizationCode = crypto.randomBytes(42).toString('hex');
|
||||||
|
|
||||||
// const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
|
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
|
||||||
|
|
||||||
//TODO: DB call to save - (userId, codeChallenge, authorizationCode, expiresAt)
|
if (codeChallenge) {
|
||||||
|
const tokens = this.appTokenRepository.create([
|
||||||
|
{
|
||||||
|
value: codeChallenge,
|
||||||
|
type: AppTokenType.CodeChallenge,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: user.defaultWorkspaceId,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: authorizationCode,
|
||||||
|
type: AppTokenType.AuthorizationCode,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: user.defaultWorkspaceId,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const redirectUrl = `${client.redirectUrl}?authorizationCode=${authorizationCode}`;
|
await this.appTokenRepository.save(tokens);
|
||||||
|
} else {
|
||||||
|
const token = this.appTokenRepository.create({
|
||||||
|
value: authorizationCode,
|
||||||
|
type: AppTokenType.AuthorizationCode,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: user.defaultWorkspaceId,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.appTokenRepository.save(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = `${
|
||||||
|
client.redirectUrl ? client.redirectUrl : authorizeAppInput.redirectUrl
|
||||||
|
}?authorizationCode=${authorizationCode}`;
|
||||||
|
|
||||||
return { redirectUrl };
|
return { redirectUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,6 @@ import { JwtData } from 'src/engine/core-modules/auth/types/jwt-data.type';
|
|||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
|
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
|
||||||
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
|
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
|
||||||
import { DEV_SEED_USER_IDS } from 'src/database/typeorm-seeds/core/users';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
@ -61,7 +60,7 @@ export class TokenService {
|
|||||||
@InjectRepository(Workspace, 'core')
|
@InjectRepository(Workspace, 'core')
|
||||||
private readonly workspaceRepository: Repository<Workspace>,
|
private readonly workspaceRepository: Repository<Workspace>,
|
||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async generateAccessToken(
|
async generateAccessToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -290,7 +289,8 @@ export class TokenService {
|
|||||||
async verifyAuthorizationCode(
|
async verifyAuthorizationCode(
|
||||||
exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||||
): Promise<ExchangeAuthCode> {
|
): Promise<ExchangeAuthCode> {
|
||||||
const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
|
const { authorizationCode, codeVerifier, clientSecret } =
|
||||||
|
exchangeAuthCodeInput;
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
authorizationCode,
|
authorizationCode,
|
||||||
@ -298,40 +298,82 @@ export class TokenService {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(codeVerifier, 'code verifier not found', NotFoundException);
|
assert(
|
||||||
|
!codeVerifier || !clientSecret,
|
||||||
|
'client secret or code verifier not found',
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: replace this with call to stateless table
|
let userId = '';
|
||||||
|
|
||||||
// assert(authObj, 'Authorization code does not exist', NotFoundException);
|
if (clientSecret) {
|
||||||
|
// TODO: replace this with call to third party apps table
|
||||||
|
// assert(client.secret, 'client secret code does not exist', ForbiddenException);
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
// assert(
|
if (codeVerifier) {
|
||||||
// authObj.expiresAt.getTime() <= Date.now(),
|
const authorizationCodeAppToken = await this.appTokenRepository.findOne({
|
||||||
// 'Authorization code expired.',
|
where: {
|
||||||
// NotFoundException,
|
value: authorizationCode,
|
||||||
// );
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// const codeChallenge = crypto
|
assert(
|
||||||
// .createHash('sha256')
|
authorizationCodeAppToken,
|
||||||
// .update(codeVerifier)
|
'Authorization code does not exist',
|
||||||
// .digest()
|
ForbiddenException,
|
||||||
// .toString('base64')
|
);
|
||||||
// .replace(/\+/g, '-')
|
|
||||||
// .replace(/\//g, '_')
|
|
||||||
// .replace(/=/g, '');
|
|
||||||
|
|
||||||
// assert(
|
assert(
|
||||||
// authObj.codeChallenge !== codeChallenge,
|
authorizationCodeAppToken.expiresAt.getTime() >= Date.now(),
|
||||||
// 'code verifier doesnt match the challenge',
|
'Authorization code expired.',
|
||||||
// ForbiddenException,
|
NotFoundException,
|
||||||
// );
|
);
|
||||||
|
|
||||||
|
const codeChallenge = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(codeVerifier)
|
||||||
|
.digest()
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
|
||||||
|
const codeChallengeAppToken = await this.appTokenRepository.findOne({
|
||||||
|
where: {
|
||||||
|
value: codeChallenge,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(
|
||||||
|
codeChallengeAppToken,
|
||||||
|
'code verifier doesnt match the challenge',
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
codeChallengeAppToken.expiresAt.getTime() >= Date.now(),
|
||||||
|
'code challenge expired.',
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
codeChallengeAppToken.userId === authorizationCodeAppToken.userId,
|
||||||
|
'authorization code / code verifier was not created by same client',
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
|
||||||
|
userId = codeChallengeAppToken.userId;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findOne({
|
const user = await this.userRepository.findOne({
|
||||||
where: { id: DEV_SEED_USER_IDS.TIM }, // TODO: replace this id with corresponding authenticated user id mappeed to authorization code
|
where: { id: userId },
|
||||||
relations: ['defaultWorkspace'],
|
relations: ['defaultWorkspace'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User is not found');
|
throw new NotFoundException('User who generated the token does not exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.defaultWorkspace) {
|
if (!user.defaultWorkspace) {
|
||||||
|
|||||||
Reference in New Issue
Block a user