Skip to content

Commit

Permalink
review comments
Browse files Browse the repository at this point in the history
  • Loading branch information
TBonnin committed Sep 26, 2024
1 parent ac70627 commit d8b2789
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 177 deletions.
15 changes: 6 additions & 9 deletions packages/keystore/lib/utils/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,23 @@ const pbkdf2 = utils.promisify(crypto.pbkdf2);

let encryption: Encryption | null = null;

function getEncryptionKey(): string | undefined {
return envs.NANGO_ENCRYPTION_KEY;
function getEncryptionKey(): string {
const encryptionKey = envs.NANGO_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('NANGO_ENCRYPTION_KEY is not set');
}
return encryptionKey;
}

export function getEncryption(): Encryption {
if (!encryption) {
const encryptionKey = getEncryptionKey();
if (!encryptionKey) {
throw new Error('NANGO_ENCRYPTION_KEY is not set');
}
encryption = new Encryption(encryptionKey);
}
return encryption;
}

export async function hashValue(val: string): Promise<string> {
const encryptionKey = getEncryptionKey();
if (!encryptionKey) {
throw new Error('NANGO_ENCRYPTION_KEY is not set');
}

return (await pbkdf2(val, encryptionKey, 310000, 32, 'sha256')).toString('base64');
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { seeders } from '@nangohq/shared';
import db, { multipleMigrations } from '@nangohq/database';
import type { DBEnvironment } from '@nangohq/types';
import { migrate as migrateKeystore } from '@nangohq/keystore';
import * as linkedProfileService from '../../services/linkedProfile.service.js';

let api: Awaited<ReturnType<typeof runServer>>;

Expand Down Expand Up @@ -43,14 +44,14 @@ describe(`POST ${endpoint}`, () => {
isError(res.json);
expect(res.json).toStrictEqual({
error: {
code: 'invalid_request',
code: 'invalid_body',
errors: [{ code: 'invalid_type', message: 'Required', path: ['linkedProfile'] }]
}
});
expect(res.res.status).toBe(400);
});

it('should fail if no linkedProfile.profileId', async () => {
it('should fail if no profileId or email', async () => {
const res = await api.fetch(endpoint, {
method: 'POST',
token: seed.env.secret_key,
Expand All @@ -63,51 +64,62 @@ describe(`POST ${endpoint}`, () => {
isError(res.json);
expect(res.json).toStrictEqual({
error: {
code: 'invalid_request',
errors: [{ code: 'invalid_type', message: 'Required', path: ['linkedProfile', 'profileId'] }]
code: 'invalid_body',
errors: [
{ code: 'invalid_type', message: 'Required', path: ['linkedProfile', 'profileId'] },
{ code: 'invalid_type', message: 'Required', path: ['linkedProfile', 'email'] }
]
}
});
expect(res.res.status).toBe(400);
});

it('should fail if new linkedProfile but no email', async () => {
it('should return new connectSessionToken', async () => {
const res = await api.fetch(endpoint, {
method: 'POST',
token: seed.env.secret_key,
body: {
linkedProfile: {
profileId: 'newId'
}
}
});

isError(res.json);
expect(res.json).toStrictEqual({
error: {
code: 'invalid_request',
errors: [{ code: 'invalid_type', message: 'email is required', path: ['linkedProfile', 'email'] }]
}
body: { linkedProfile: { profileId: 'someId', email: 'a@b.com' } }
});
expect(res.res.status).toBe(400);
isSuccess(res.json);
});

it('should return new connectSessionToken', async () => {
it('should update the linked profile if needed', async () => {
// first request create a linked profile
const profileId = 'knownId';
await api.fetch(endpoint, {
const res = await api.fetch(endpoint, {
method: 'POST',
token: seed.env.secret_key,
body: { linkedProfile: { profileId, email: 'a@b.com' } }
});
isSuccess(res.json);

// 2nd request doesn't require the email
const res = await api.fetch(endpoint, {
// second request with same profileId update the linked profile
const newEmail = 'x@y.com';
const newDisplayName = 'Mr XY';
const newOrgId = 'orgId';
const newOrgDisplayName = 'OrgName';
const res2 = await api.fetch(endpoint, {
method: 'POST',
token: seed.env.secret_key,
body: {
linkedProfile: { profileId }
linkedProfile: {
profileId,
email: newEmail,
displayName: newDisplayName,
organization: { id: newOrgId, displayName: newOrgDisplayName }
}
}
});
isSuccess(res.json);
isSuccess(res2.json);
const getProfile = await linkedProfileService.getLinkedProfile(db.knex, {
profileId,
accountId: seed.env.account_id,
environmentId: seed.env.id
});
const profile = getProfile.unwrap();
expect(profile.email).toBe(newEmail);
expect(profile.displayName).toBe(newDisplayName);
expect(profile.organization?.id).toBe(newOrgId);
expect(profile.organization?.displayName).toBe(newOrgDisplayName);
});
});
135 changes: 78 additions & 57 deletions packages/server/lib/controllers/connect/postSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,53 @@ import type { PostConnectSessions } from '@nangohq/types';
import { z } from 'zod';
import db from '@nangohq/database';
import { asyncWrapper } from '../../utils/asyncWrapper.js';
import { validateRequest } from '@nangohq/utils';
import * as keystore from '@nangohq/keystore';
import * as linkedProfileService from '../../services/linkedProfile.service.js';
import * as connectSessionService from '../../services/connectSession.service.js';
import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';

const validate = validateRequest<PostConnectSessions>({
parseBody: (data) =>
z
const bodySchema = z
.object({
linkedProfile: z
.object({
linkedProfile: z.object({
profileId: z.string().max(255).min(1),
email: z.string().email().optional(),
displayName: z.string().max(255).optional(),
organization: z
.object({
organizationId: z.string().max(255).min(0),
displayName: z.string().max(255).optional()
})
.optional()
}),
allowedIntegrations: z.array(z.string()).optional(),
integrationsConfigDefaults: z
.record(
z.object({
connectionConfig: z.record(z.unknown())
})
)
profileId: z.string().max(255).min(1),
email: z.string().email().min(5),
displayName: z.string().max(255).optional(),
organization: z
.object({
id: z.string().max(255).min(0),
displayName: z.string().max(255).optional()
})
.strict()
.optional()
})
.strict()
.parse(data)
});
.strict(),
allowedIntegrations: z.array(z.string()).optional(),
integrationsConfigDefaults: z
.record(
z
.object({
connectionConfig: z.record(z.unknown())
})
.strict()
)
.optional()
})
.strict();

export const postConnectSessions = asyncWrapper<PostConnectSessions>(async (req, res) => {
const emptyQuery = requireEmptyQuery(req);
if (emptyQuery) {
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } });
return;
}

const val = bodySchema.safeParse(req.body);
if (!val.success) {
res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(val.error) } });
return;
}

const handler = asyncWrapper<PostConnectSessions>(async (req, res) => {
await db.knex.transaction(async (trx) => {
// Check if the linkedProfile exists in the database
const getLinkedProfile = await linkedProfileService.getLinkedProfile(trx, {
Expand All @@ -46,44 +59,54 @@ const handler = asyncWrapper<PostConnectSessions>(async (req, res) => {

let linkedProfileId: number;
if (getLinkedProfile.isErr()) {
if (getLinkedProfile.error.code !== 'not_found') {
res.status(500).send({ error: { code: 'internal_error', message: 'Failed to get linked profile' } });
return;
}
// create linked profile if it doesn't exist yet
if (getLinkedProfile.error.code === 'not_found') {
// fail if linkedProfile doesn't exist and email is not provided
if (!req.body.linkedProfile.email) {
res.status(400).send({
error: {
code: 'invalid_request',
errors: [{ code: 'invalid_type', message: 'email is required', path: ['linkedProfile', 'email'] }]
}
});
return;
}

const createLinkedProfile = await linkedProfileService.createLinkedProfile(trx, {
profileId: req.body.linkedProfile.profileId,
const createLinkedProfile = await linkedProfileService.createLinkedProfile(trx, {
profileId: req.body.linkedProfile.profileId,
email: req.body.linkedProfile.email,
displayName: req.body.linkedProfile.displayName || null,
organization: req.body.linkedProfile.organization?.id
? {
id: req.body.linkedProfile.organization.id,
displayName: req.body.linkedProfile.organization.displayName || null
}
: null,
accountId: res.locals.account.id,
environmentId: res.locals.environment.id
});
if (createLinkedProfile.isErr()) {
res.status(500).send({ error: { code: 'internal_error', message: 'Failed to create linked profile' } });
return;
}
linkedProfileId = createLinkedProfile.value.id;
} else {
const shouldUpdate =
getLinkedProfile.value.email !== req.body.linkedProfile.email ||
getLinkedProfile.value.displayName !== req.body.linkedProfile.displayName ||
getLinkedProfile.value.organization?.id !== req.body.linkedProfile.organization?.id ||
getLinkedProfile.value.organization?.displayName !== req.body.linkedProfile.organization?.displayName;
if (shouldUpdate) {
const updateLinkedProfile = await linkedProfileService.updateLinkedProfile(trx, {
profileId: getLinkedProfile.value.profileId,
accountId: res.locals.account.id,
environmentId: res.locals.environment.id,
email: req.body.linkedProfile.email,
displayName: req.body.linkedProfile.displayName || null,
organization: req.body.linkedProfile.organization?.organizationId
organization: req.body.linkedProfile.organization?.id
? {
organizationId: req.body.linkedProfile.organization.organizationId,
id: req.body.linkedProfile.organization.id,
displayName: req.body.linkedProfile.organization.displayName || null
}
: null,
accountId: res.locals.account.id,
environmentId: res.locals.environment.id
: null
});
if (createLinkedProfile.isErr()) {
res.status(500).send({ error: { code: 'internal_error', message: 'Failed to create linked profile' } });
if (updateLinkedProfile.isErr()) {
res.status(500).send({ error: { code: 'internal_error', message: 'Failed to update linked profile' } });
return;
}
linkedProfileId = createLinkedProfile.value.id;
} else {
console.log(getLinkedProfile.error);
res.status(500).send({ error: { code: 'internal_error', message: 'Failed to get linked profile' } });
return;
}
} else {
// TODO: what if linkedProfile exists but email, displayName, or organization is different?
linkedProfileId = getLinkedProfile.value.id;
}

Expand Down Expand Up @@ -113,9 +136,7 @@ const handler = asyncWrapper<PostConnectSessions>(async (req, res) => {
return;
}
const [token, privateKey] = createPrivateKey.value;
res.status(201).send({ token, expiresAt: privateKey.expiresAt! });
res.status(201).send({ data: { token, expiresAt: privateKey.expiresAt! } });
return;
});
});

export const postConnectSessions = [validate, handler];
2 changes: 1 addition & 1 deletion packages/server/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ publicAPI.route('/flow/configs').get(apiAuth, flowController.getFlowConfig.bind(
publicAPI.route('/scripts/config').get(apiAuth, flowController.getFlowConfig.bind(flowController));
publicAPI.route('/action/trigger').post(apiAuth, syncController.triggerAction.bind(syncController)); //TODO: to deprecate

publicAPI.route('/connect/sessions').post(apiAuth, ...postConnectSessions);
publicAPI.route('/connect/sessions').post(apiAuth, postConnectSessions);

publicAPI.route('/v1/*').all(apiAuth, syncController.actionOrModel.bind(syncController));

Expand Down
Loading

0 comments on commit d8b2789

Please sign in to comment.