From 7b9c2ec90a8d97d5fe2d1fd2e16a03f39f2d61c2 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 9 Sep 2022 14:36:18 +0200 Subject: [PATCH] feat: import auth0 accounts into supertokens when doing a password reset (#349) * feat: import auth0 accounts into supertokens when doing a password reset * refactor: more detailed error messages --- .../src/modules/shared/providers/storage.ts | 1 + packages/services/api/src/shared/entities.ts | 1 + packages/services/server/src/index.ts | 30 +++++++ packages/services/storage/src/index.ts | 20 +++++ packages/web/app/src/config/backend-config.ts | 89 +++++++++++++++++++ 5 files changed, 141 insertions(+) diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 648ab1fd3..b50cba19a 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -46,6 +46,7 @@ export interface PersistedOperationSelector extends ProjectSelector { export interface Storage { getUserBySuperTokenId(_: { superTokensUserId: string }): Promise; setSuperTokensUserId(_: { auth0UserId: string; superTokensUserId: string; externalUserId: string }): Promise; + getUserWithoutAssociatedSuperTokenIdByAuth0Email(_: { email: string }): Promise; getUserById(_: { id: string }): Promise; createUser(_: { diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 7c2e3fb1d..85f6c30c9 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -169,6 +169,7 @@ export interface User { provider: AuthProvider; superTokensUserId: string | null; isAdmin: boolean; + externalAuthUserId: string | null; } export interface Member { diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index b0935b1bd..17a192fbb 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -25,6 +25,10 @@ const LegacySetUserIdMappingPayloadModel = zod.object({ superTokensUserId: zod.string(), }); +const LegacyCheckAuth0EmailUserExistsPayloadModel = zod.object({ + email: zod.string(), +}); + export async function main() { Sentry.init({ serverName: 'api', @@ -302,6 +306,32 @@ export async function main() { reply.status(200).send(); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void }, }); + server.route({ + method: 'POST', + url: '/__legacy/check_auth0_email_user_without_associated_supertoken_id_exists', + async handler(req, reply) { + if (req.headers['x-authorization'] !== authLegacyAPIKey) { + reply.status(401).send({ error: 'Invalid update user id mapping key.', code: 'ERR_INVALID_KEY' }); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void + return; + } + + const { email } = LegacyCheckAuth0EmailUserExistsPayloadModel.parse(req.body); + + const user = await storage.getUserWithoutAssociatedSuperTokenIdByAuth0Email({ + email, + }); + + await reply.status(200).send({ + user: user + ? { + id: user.id, + email: user?.email, + auth0UserId: user.externalAuthUserId, + } + : null, + }); + }, + }); } if (process.env.METRICS_ENABLED === 'true') { diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 5992089e0..a3754ee55 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -84,6 +84,7 @@ export async function createStorage(connection: string): Promise { fullName: user.full_name, displayName: user.display_name, isAdmin: user.is_admin ?? false, + externalAuthUserId: user.external_auth_user_id ?? null, }; } @@ -294,6 +295,25 @@ export async function createStorage(connection: string): Promise { return null; }, + async getUserWithoutAssociatedSuperTokenIdByAuth0Email({ email }) { + const user = await pool.maybeOne>(sql` + SELECT + * + FROM + public."users" + WHERE + "email" = ${email} + AND "supertoken_user_id" IS NULL + AND "external_auth_user_id" LIKE 'auth0|%' + LIMIT 1 + `); + + if (user) { + return transformUser(user); + } + + return null; + }, async setSuperTokensUserId({ auth0UserId, superTokensUserId, externalUserId }) { await pool.query(sql` UPDATE diff --git a/packages/web/app/src/config/backend-config.ts b/packages/web/app/src/config/backend-config.ts index 3ebd25a57..c30dd8c3b 100644 --- a/packages/web/app/src/config/backend-config.ts +++ b/packages/web/app/src/config/backend-config.ts @@ -6,6 +6,7 @@ import type { TypeInput as ThirdPartEmailPasswordTypeInput } from 'supertokens-n import { fetch } from 'cross-undici-fetch'; import { appInfo } from './app-info'; import zod from 'zod'; +import * as crypto from 'crypto'; import { createTRPCClient } from '@trpc/client'; import type { EmailsApi } from '@hive/emails'; @@ -180,6 +181,43 @@ export const backendConfig = (): TypeInput => { const getAuth0Overrides = (config: LegacyAuth0ConfigEnabled) => { const override: ThirdPartEmailPasswordTypeInput['override'] = { + apis(originalImplementation) { + return { + ...originalImplementation, + async generatePasswordResetTokenPOST(input) { + const email = input.formFields.find(formField => formField.id === 'email')?.value; + + if (email) { + // We first use the existing implementation for looking for users within supertokens. + const users = await ThirdPartyEmailPasswordNode.getUsersByEmail(email); + + // If there is no email/password SuperTokens user yet, we need to check if there is an Auth0 user for this email. + if (users.some(user => user.thirdParty == null) === false) { + // RPC call to check if email/password user exists in Auth0 + const dbUser = await checkWhetherAuth0EmailUserWithoutAssociatedSuperTokensIdExists(config, { email }); + + if (dbUser) { + // If we have this user within our database we create our new supertokens user + const newUserResult = await ThirdPartyEmailPasswordNode.emailPasswordSignUp( + dbUser.email, + await generateRandomPassword() + ); + + if (newUserResult.status === 'OK') { + // link the db record to the new supertokens user + await setUserIdMapping(config, { + auth0UserId: dbUser.auth0UserId, + supertokensUserId: newUserResult.user.id, + }); + } + } + } + } + + return await originalImplementation.generatePasswordResetTokenPOST!(input); + }, + }; + }, functions(originalImplementation) { return { ...originalImplementation, @@ -358,6 +396,45 @@ async function setUserIdMapping( } } +const CheckAuth0EmailUserExistsResponseModel = zod.object({ + user: zod.nullable(zod.object({ id: zod.string(), email: zod.string(), auth0UserId: zod.string() })), +}); + +/** + * Check whether a specific user that SIGNED UP VIA EMAIL and password THAT DOES NOT YET EXIST IN SUPER TOKENS exists in the database as an Auth0 user. + */ +async function checkWhetherAuth0EmailUserWithoutAssociatedSuperTokensIdExists( + config: LegacyAuth0ConfigEnabled, + params: { email: string } +): Promise['user']> { + const response = await fetch( + config['AUTH_LEGACY_AUTH0_INTERNAL_API_ENDPOINT'] + + '/check_auth0_email_user_without_associated_supertoken_id_exists', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-authorization': config['AUTH_LEGACY_AUTH0_INTERNAL_API_KEY'], + }, + body: JSON.stringify({ + email: params.email, + }), + } + ); + + const body = await response.text(); + + if (response.status !== 200) { + throw new Error( + `Failed to check whether the Auth0 email user without an associated supertokenId exists. Status: ${response.status}. Body: ${body}` + ); + } + + const { user } = CheckAuth0EmailUserExistsResponseModel.parse(JSON.parse(body)); + + return user; +} + /** * Generate a Auth0 access token that is required for making API calls to Auth0. */ @@ -381,3 +458,15 @@ const generateAuth0AccessToken = async (config: LegacyAuth0ConfigEnabled): Promi return JSON.parse(body).access_token; }; + +async function generateRandomPassword(): Promise { + return await new Promise((resolve, reject) => + crypto.randomBytes(20, (err, buf) => { + if (err) { + reject(err); + return; + } + resolve(buf.toString('hex')); + }) + ); +}