Skip to content

Commit

Permalink
feat: Oauth with PKCE (#4648)
Browse files Browse the repository at this point in the history
* authorizeApp and exchangeAuthcode methods

* module rename

* import fix

* lint fix

* fix import
  • Loading branch information
AdityaPimpalkar authored Mar 27, 2024
1 parent f00b9f2 commit 0391bf6
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/twenty-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class AuthorizeApp {
@Field(() => String)
redirectUrl: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Field, ArgsType } from '@nestjs/graphql';

@ArgsType()
export class AuthorizeAppInput {
@Field(() => String)
clientId: string;

@Field(() => String)
codeChallenge: string;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ArgsType, Field } from '@nestjs/graphql';

@ArgsType()
export class ExchangeAuthCodeInput {
@Field(() => String)
authorizationCode: string;

@Field(() => String)
codeVerifier: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>) => {
Expand Down

0 comments on commit 0391bf6

Please sign in to comment.