feat: Oauth with PKCE (#4648)

* authorizeApp and exchangeAuthcode methods

* module rename

* import fix

* lint fix

* fix import
This commit is contained in:
Aditya Pimpalkar
2024-03-27 20:18:07 +00:00
committed by GitHub
parent f00b9f229a
commit 0391bf65f2
9 changed files with 174 additions and 0 deletions

View File

@ -62,3 +62,4 @@ SIGN_IN_PREFILLED=true
# API_RATE_LIMITING_TTL=
# API_RATE_LIMITING_LIMIT=
# MUTATION_MAXIMUM_RECORD_AFFECTED=100
# CHROME_EXTENSION_REDIRECT_URL=https://bggmipldbceihilonnbpgoeclgbkblkp.chromiumapps.com

View File

@ -27,6 +27,10 @@ import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.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 { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { TokenService } from './services/token.service';
@ -131,6 +135,26 @@ export class AuthResolver {
return result;
}
@Mutation(() => AuthorizeApp)
@UseGuards(JwtAuthGuard)
authorizeApp(@Args() authorizeAppInput: AuthorizeAppInput): AuthorizeApp {
const authorizedApp =
this.authService.generateAuthorizationCode(authorizeAppInput);
return authorizedApp;
}
@Query(() => ExchangeAuthCode)
async exchangeAuthorizationCode(
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
) {
const tokens = await this.tokenService.verifyAuthorizationCode(
exchangeAuthCodeInput,
);
return tokens;
}
@Mutation(() => AuthTokens)
@UseGuards(JwtAuthGuard)
async generateJWT(

View File

@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class AuthorizeApp {
@Field(() => String)
redirectUrl: string;
}

View File

@ -0,0 +1,10 @@
import { Field, ArgsType } from '@nestjs/graphql';
@ArgsType()
export class AuthorizeAppInput {
@Field(() => String)
clientId: string;
@Field(() => String)
codeChallenge: string;
}

View File

@ -0,0 +1,15 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
@ObjectType()
export class ExchangeAuthCode {
@Field(() => AuthToken)
accessToken: AuthToken;
@Field(() => AuthToken)
refreshToken: AuthToken;
@Field(() => AuthToken)
loginToken: AuthToken;
}

View File

@ -0,0 +1,10 @@
import { ArgsType, Field } from '@nestjs/graphql';
@ArgsType()
export class ExchangeAuthCodeInput {
@Field(() => String)
authorizationCode: string;
@Field(() => String)
codeVerifier: string;
}

View File

@ -6,6 +6,8 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'node:crypto';
import { Repository } from 'typeorm';
import { render } from '@react-email/components';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
@ -27,6 +29,8 @@ import { EnvironmentService } from 'src/engine/integrations/environment/environm
import { EmailService } from 'src/engine/integrations/email/email.service';
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
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 { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
import { TokenService } from './token.service';
@ -173,6 +177,39 @@ export class AuthService {
};
}
generateAuthorizationCode(
authorizeAppInput: AuthorizeAppInput,
): AuthorizeApp {
// TODO: replace with db call to - third party app table
const apps = [
{
id: 'chrome',
name: 'Chrome Extension',
redirectUrl: `${this.environmentService.get(
'CHROME_EXTENSION_REDIRECT_URL',
)}`,
},
];
const { clientId } = authorizeAppInput;
const client = apps.find((app) => app.id === clientId);
if (!client) {
throw new NotFoundException(`Invalid client '${clientId}'`);
}
const authorizationCode = crypto.randomBytes(42).toString('hex');
// const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
//TODO: DB call to save - (userId, codeChallenge, authorizationCode, expiresAt)
const redirectUrl = `${client.redirectUrl}?authorizationCode=${authorizationCode}`;
return { redirectUrl };
}
async updatePassword(
userId: string,
newPassword: string,

View File

@ -41,6 +41,9 @@ import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-
import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity';
import { JwtData } from 'src/engine/core-modules/auth/types/jwt-data.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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 { DEV_SEED_USER_IDS } from 'src/database/typeorm-seeds/core/users';
@Injectable()
export class TokenService {
@ -281,6 +284,71 @@ export class TokenService {
};
}
async verifyAuthorizationCode(
exchangeAuthCodeInput: ExchangeAuthCodeInput,
): Promise<ExchangeAuthCode> {
const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
assert(
authorizationCode,
'Authorization code not found',
NotFoundException,
);
assert(codeVerifier, 'code verifier not found', NotFoundException);
// TODO: replace this with call to stateless table
// assert(authObj, 'Authorization code does not exist', NotFoundException);
// assert(
// authObj.expiresAt.getTime() <= Date.now(),
// 'Authorization code expired.',
// NotFoundException,
// );
// const codeChallenge = crypto
// .createHash('sha256')
// .update(codeVerifier)
// .digest()
// .toString('base64')
// .replace(/\+/g, '-')
// .replace(/\//g, '_')
// .replace(/=/g, '');
// assert(
// authObj.codeChallenge !== codeChallenge,
// 'code verifier doesnt match the challenge',
// ForbiddenException,
// );
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
relations: ['defaultWorkspace'],
});
if (!user) {
throw new NotFoundException('User is not found');
}
if (!user.defaultWorkspace) {
throw new NotFoundException('User does not have a default workspace');
}
const accessToken = await this.generateAccessToken(
user.id,
user.defaultWorkspaceId,
);
const refreshToken = await this.generateRefreshToken(user.id);
const loginToken = await this.generateLoginToken(user.email);
return {
accessToken,
refreshToken,
loginToken,
};
}
async verifyRefreshToken(refreshToken: string) {
const secret = this.environmentService.get('REFRESH_TOKEN_SECRET');
const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN');

View File

@ -303,6 +303,8 @@ export class EnvironmentVariables {
CALENDAR_PROVIDER_GOOGLE_ENABLED: boolean = false;
AUTH_GOOGLE_APIS_CALLBACK_URL: string;
CHROME_EXTENSION_REDIRECT_URL: string;
}
export const validate = (config: Record<string, unknown>) => {