Skip to content

Commit

Permalink
merge: #2972
Browse files Browse the repository at this point in the history
2972: feat(auth) handle email verification and refreshing from auth0 r=theoephraim a=theoephraim

- add auth api endpoints to refresh profile from auth0 and resend verification email
- refresh auth0 profile (which includes email verification status) while connecting to workspace
- add error banner on auth portal to get users to verify their email
- block workspace login widget when email is not verified
- general cleanup around user creation/login and profile fetching logic
- this uses a new "machine to machine" auth0 app, so we are connecting as our app to fetch user profile data, rather than as the user themselves (using their token)


NOTE - we'll need to add a new env var for the m2m auth0 client secret to deploy

Co-authored-by: Theo Ephraim <[email protected]>
  • Loading branch information
si-bors-ng[bot] and Theo Ephraim authored Nov 27, 2023
2 parents 19d6403 + 53193ed commit a56061b
Show file tree
Hide file tree
Showing 18 changed files with 1,878 additions and 532 deletions.
32 changes: 32 additions & 0 deletions app/auth-portal/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,31 @@
<div
class="m-lg p-lg dark:bg-neutral-800 bg-neutral-200 rounded-md"
>
<!-- email verification warning w/ buttons to help resolve -->
<ErrorMessage v-if="user && !user?.emailVerified" class="mb-lg">
<Inline spacing="md" alignY="center">
<p>Please verify your email address</p>

<VButton
tone="shade"
variant="transparent"
size="sm"
:requestStatus="refreshAuth0Req"
@click="authStore.REFRESH_AUTH0_PROFILE"
>Already verified?</VButton
>
<VButton
v-if="!resendEmailVerificationReq.isSuccess"
tone="shade"
variant="transparent"
size="sm"
:requestStatus="resendEmailVerificationReq"
@click="authStore.RESEND_EMAIL_VERIFICATION"
>Resend Email</VButton
>
</Inline>
</ErrorMessage>

<RouterView />
</div>
</div>
Expand Down Expand Up @@ -186,6 +211,8 @@ import {
VButton,
DropdownMenu,
DropdownMenuItem,
ErrorMessage,
Inline,
} from "@si/vue-lib/design-system";
import "floating-vue/dist/style.css";
Expand Down Expand Up @@ -236,6 +263,11 @@ onMounted(() => {
const authStore = useAuthStore();
const checkAuthReq = authStore.getRequestStatus("CHECK_AUTH");
const refreshAuth0Req = authStore.getRequestStatus("REFRESH_AUTH0_PROFILE");
const resendEmailVerificationReq = authStore.getRequestStatus(
"RESEND_EMAIL_VERIFICATION",
);
const userIsLoggedIn = computed(() => authStore.userIsLoggedIn);
const user = computed(() => authStore.user);
Expand Down
11 changes: 11 additions & 0 deletions app/auth-portal/src/components/WorkspaceLinkWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
'cursor-pointer z-20',
)
"
@click="clickHandler"
@mousedown="tracker.trackEvent('workspace_launcher_widget_click')"
>
<Icon v-if="!compact" name="laptop" size="lg" />
Expand Down Expand Up @@ -150,6 +151,7 @@ import { useOnboardingStore } from "@/store/onboarding.store";
import { API_HTTP_URL } from "@/store/api";
import { tracker } from "@/lib/posthog";
import { useAuthStore } from "@/store/auth.store";
const featureFlagsStore = useFeatureFlagsStore();
Expand Down Expand Up @@ -190,4 +192,13 @@ const workspaceNameTooltip = computed(() => {
const emit = defineEmits<{
(e: "edit"): void;
}>();
const authStore = useAuthStore();
function clickHandler(e: MouseEvent) {
if (authStore.user && !authStore.user.emailVerified) {
// eslint-disable-next-line no-alert
alert("You must verify your email before you can log into a workspace");
e.preventDefault();
}
}
</script>
3 changes: 3 additions & 0 deletions app/auth-portal/src/store/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const api = Axios.create({
baseURL: API_HTTP_URL,
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (window) (window as any).api = api;

// // add axios interceptors to add auth headers, handle logout errors, etc...
// api.interceptors.request.use((config) => {
// // inject auth token from the store as a custom header
Expand Down
32 changes: 32 additions & 0 deletions app/auth-portal/src/store/auth.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,38 @@ export const useAuthStore = defineStore("auth", {
},
});
},

async REFRESH_AUTH0_PROFILE() {
if (!this.user) throw new Error("User not loaded");
return new ApiRequest<{ user: User }>({
method: "post",
url: `/users/${this.user.id}/refresh-auth0-profile`,
onSuccess: (response) => {
this.user = response.user;
},
});
},
async RESEND_EMAIL_VERIFICATION() {
if (!this.user) throw new Error("User not loaded");
return new ApiRequest<{ user: User }>({
method: "post",
url: `/users/${this.user.id}/resend-email-verification`,
onSuccess: (response) => {
this.user = response.user;
},
onFail: (response) => {
// if we see this error, it means the backend will have updated the user already too
// so we can optimistically update the user and refresh the user data
if (response.kind === "EmailAlreadyVerified") {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.user!.emailVerified = true;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.LOAD_USER();
}
},
});
},

// All of the questions answered in onboarding are put into an object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async COMPLETE_PROFILE(onboardingQuestions: Record<string, any>) {
Expand Down
2 changes: 2 additions & 0 deletions bin/auth-api/.env
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ JWT_PUBLIC_KEY_PATH="../../config/keys/dev.jwt_signing_public_key.pem"
AUTH0_DOMAIN=systeminit.auth0.com
AUTH0_CLIENT_ID=1A5RCj7i2hr5kPDwhw0RwBIX8DT2gvyy
AUTH0_CLIENT_SECRET=fill-in-real-key # must set in .env.local to run auth api locally
AUTH0_M2M_CLIENT_ID=1v8ff9tKOZw0u8As4gib1HmvxefaX0nK
AUTH0_M2M_CLIENT_SECRET=fill-in-real-key # must set in .env.local to run auth api locally

POSTHOG_PUBLIC_KEY=phc_KpehlXOqtU44B2MeW6WjqR09NxRJCYEiUReA58QcAYK
POSTHOG_API_HOST=https://e.systeminit.com
Expand Down
2 changes: 1 addition & 1 deletion bin/auth-api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ node_modules
.env.local
dist
*.ignore
.nyc_output
.tap
10 changes: 6 additions & 4 deletions bin/auth-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "node --watch --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm ./src/main.ts",
"dev:run": "node --watch --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm",
"build": "tsc --incremental false -p tsconfig-build.json",
"boot": "node --experimental-specifier-resolution=node ./dist/main.js",
"boot-ts": "node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm ./src/main.ts",
Expand Down Expand Up @@ -73,24 +74,25 @@
"@types/supertest": "^2.0.12",
"@types/tap": "^15.0.8",
"@types/uuid": "^9.0.1",
"@types/wtfnode": "^0.7.3",
"chai": "^4.3.7",
"chai-subset": "^1.6.0",
"eslint": "^8.36.0",
"nock": "^13.3.1",
"supertest": "^6.3.3",
"tap": "^16.3.4",
"tap": "^18.6.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.5"
"typescript": "^4.9.5",
"wtfnode": "^0.9.1"
},
"tap": {
"coverage": false,
"allow-incomplete-coverage": true,
"node-arg": [
"--no-warnings",
"--loader",
"ts-node/esm",
"--experimental-specifier-resolution=node"
],
"ts": false,
"before": "test/helpers/global-setup.ts",
"after": "test/helpers/global-teardown.ts"
},
Expand Down
13 changes: 7 additions & 6 deletions bin/auth-api/src/lib/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,27 @@ export const redis = new IORedis({
}) as ExtendedIORedis;

// add helper to get/set json objects without worrying about JSON serialization
async function setJSON(
async function setJSON<T extends { [key: string]: any } >(
this: Redis,
key: string,
payload: Record<string, any>,
payload: T,
options?: { expiresIn?: number },
) {
let args: string[] = [];
// seconds to expire
if (options?.expiresIn) args = ['EX', options.expiresIn.toString()];
return this.set(key, JSON.stringify(payload), ...args as any);
}
async function getJSON(
async function getJSON<T extends { [key: string]: any } >(
this: Redis,
key: string,
options?: { delete?: boolean },
) {
): Promise<T | null> {
const result = await this.get(key);
if (!result) return result;
if (result === null) return result;
if (!result) return null; // treat empty strings as null
if (options?.delete) await this.del(key);
return JSON.parse(result);
return JSON.parse(result) as T;
}

export type ExtendedIORedis = Redis & {
Expand Down
1 change: 0 additions & 1 deletion bin/auth-api/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ router.get("/auth/login-callback", async (ctx) => {
}

const { profile } = await completeAuth0TokenExchange(reqQuery.code);

const user = await createOrUpdateUserFromAuth0Details(profile);
// TODO: create/update user, send to posthog, etc...

Expand Down
25 changes: 24 additions & 1 deletion bin/auth-api/src/routes/user.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { validate } from "../lib/validation-helpers";
import { findLatestTosForUser, saveTosAgreement } from "../services/tos.service";

import { CustomRouteContext } from '../custom-state';
import { saveUser } from '../services/users.service';
import { refreshUserAuth0Profile, saveUser } from '../services/users.service';
import { resendAuth0EmailVerification } from '../services/auth0.service';
import { router } from ".";

router.get("/whoami", async (ctx) => {
Expand Down Expand Up @@ -93,6 +94,28 @@ router.post("/users/:userId/complete-profile", async (ctx) => {
ctx.body = { user };
});

router.post("/users/:userId/refresh-auth0-profile", async (ctx) => {
const user = await handleUserIdParam(ctx);
await refreshUserAuth0Profile(user);
ctx.body = { user };
});
router.post("/users/:userId/resend-email-verification", async (ctx) => {
const user = await handleUserIdParam(ctx);
if (!user.auth0Id) {
throw new ApiError('Conflict', 'User has no auth0 id');
}
if (user.emailVerified) {
throw new ApiError('Conflict', 'EmailAlreadyVerified', 'Email is already verified');
}
await refreshUserAuth0Profile(user);
if (user.emailVerified) {
throw new ApiError('Conflict', 'EmailAlreadyVerified', 'Email is already verified');
}

await resendAuth0EmailVerification(user.auth0Id);
ctx.body = { success: true };
});

router.get("/tos-details", async (ctx) => {
if (!ctx.state.authUser) {
throw new ApiError('Unauthorized', 'You are not logged in');
Expand Down
10 changes: 8 additions & 2 deletions bin/auth-api/src/routes/workspace.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { nanoid } from "nanoid";
import { z } from 'zod';
import { ApiError } from "../lib/api-error";
import { getCache, setCache } from "../lib/cache";
import { getUserById } from "../services/users.service";
import { getUserById, refreshUserAuth0Profile } from "../services/users.service";
import {
createWorkspace,
getUserWorkspaces,
Expand Down Expand Up @@ -189,8 +189,14 @@ router.get("/workspaces/:workspaceId/go", async (ctx) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const authUser = ctx.state.authUser!;

// we require the user to have verified their email before they can log into a workspace
if (!authUser.emailVerified) {
throw new ApiError('Unauthorized', "System Initiative Requires Verified Emails to access Workspaces. Check your registered email for Verification email from SI Auth Portal.");
// we'll first refresh from auth0 to make sure its actually not verified
await refreshUserAuth0Profile(authUser);
// then throw an error
if (!authUser.emailVerified) {
throw new ApiError('Unauthorized', 'EmailNotVerified', "System Initiative Requires Verified Emails to access Workspaces. Check your registered email for Verification email from SI Auth Portal.");
}
}

// generate a new single use authentication code that we will send to the instance
Expand Down
Loading

0 comments on commit a56061b

Please sign in to comment.