Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: logged in means authenticated & authorized #709

Merged
merged 8 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion quadratic-client/src/api/fetchFromApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ export async function fetchFromApi<T>(
): Promise<z.infer<typeof schema>> {
// We'll automatically inject additional headers to the request, starting with auth
const isAuthenticated = await authClient.isAuthenticated();
const token = isAuthenticated ? await authClient.getTokenOrRedirect() : '';
const headers = new Headers(init.headers);
if (isAuthenticated) {
headers.set('Authorization', `Bearer ${await authClient.getToken()}`);
headers.set('Authorization', `Bearer ${token}`);
}
// And if we're submitting `FormData`, let the browser set the content-type automatically
// This allows files to upload properly. Otherwise, we assume it's JSON.
Expand Down
77 changes: 64 additions & 13 deletions quadratic-client/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Auth0Client, User, createAuth0Client } from '@auth0/auth0-spa-js';
import * as Sentry from '@sentry/react';
import { useEffect } from 'react';
import { LoaderFunction, LoaderFunctionArgs, redirect } from 'react-router-dom';
import { ROUTES } from './constants/routes';

Expand Down Expand Up @@ -45,7 +46,7 @@ interface AuthClient {
login(redirectTo: string, isSignupFlow?: boolean): Promise<void>;
handleSigninRedirect(): Promise<void>;
logout(): Promise<void>;
getToken(): Promise<string | void>;
getTokenOrRedirect(): Promise<string>;
}

export const authClient: AuthClient = {
Expand All @@ -71,6 +72,7 @@ export const authClient: AuthClient = {
new URLSearchParams([['redirectTo', redirectTo]]).toString(),
},
});
await waitForAuth0ClientToRedirect();
},
async handleSigninRedirect() {
const query = window.location.search;
Expand All @@ -82,43 +84,71 @@ export const authClient: AuthClient = {
async logout() {
const client = await getClient();
await client.logout({ logoutParams: { returnTo: window.location.origin } });
// Not sure why this is the case, but manually waiting for this is what
// makes it work. Auth0 will redirect once it actually does the logout,
// otherwise this doesn't wait and it "logs out" too fast and you don't
// actually log out
await new Promise((resolve) => setTimeout(resolve, 10000));
await waitForAuth0ClientToRedirect();
},
async getToken() {
/**
* Tries to get a token for the current user from the auth0 client.
* If the token is still valid, it'll pull it from a cache. If it’s expired,
* it will fail and we will manually redirect the user to auth0 to re-authenticate
* and get a new token.
*/
async getTokenOrRedirect() {
const client = await getClient();

try {
const token = await client.getTokenSilently();
return token;
} catch (e) {
return this.login(new URL(window.location.href).pathname);
await this.login(new URL(window.location.href).pathname);
return '';
}
},
};

/**
* Utility function for use in route loaders.
* If the user is not logged in and tries to access a protected route, we redirect
* them to the login page with a `from` parameter that allows login to redirect back
* to current page upon successful authentication
* If the user is not logged in (or don't have an auth token) and tries to
* access a protected route, we redirect them to the login page with a `from`
* parameter that allows login to redirect back to current page upon successful
* authentication.
*/
export function protectedRouteLoaderWrapper(loaderFn: LoaderFunction): LoaderFunction {
return async (loaderFnArgs: LoaderFunctionArgs) => {
const { request } = loaderFnArgs;
let isAuthenticated = await authClient.isAuthenticated();
const isAuthenticated = await authClient.isAuthenticated();

// If the user isn't authenciated, redirect them to login
if (!isAuthenticated) {
let params = new URLSearchParams();
params.set('from', new URL(request.url).pathname);
return redirect(ROUTES.LOGIN + '?' + params.toString());
}

// If the user is authenticated, make sure we have a valid token
// before we load any of the app
await authClient.getTokenOrRedirect();

return loaderFn(loaderFnArgs);
};
}

/**
* In cases where we call the auth0 client and it redirects the user to the
* auth0 website (e.g. for `.login` and `.logout`, presumably via changing
* `window.location`) we have to manually wait for the auth0 client.
*
* Why? Because even though auth0's client APIs are async, they seem to
* complete immediately and our app's code continues before `window.location`
* kicks in.
*
* So this function ensures our whole app pauses while the auth0 lib does its
* thing and kicks the user over to auth0.com
*
* We only use this when we _want_ to pause everything and wait to redirect
*/
export function waitForAuth0ClientToRedirect() {
return new Promise((resolve) => setTimeout(resolve, 10000));
}

/**
* Utility function parse the domain from a url
*/
Expand All @@ -133,3 +163,24 @@ export function parseDomain(url: string): string {
return url.match(/(?:(?!:).)*/)![0] ?? url;
}
}

/**
* Used in the dashboard and the app to ensure the user’s auth token always
* remains valid. If at any point it expires, we redirect for a new one.
*
* Because this runs in both the app and the dashboard, we only want to check
* for a token if the user is authenticated. If they're not, it's probably
* a shared public file in the app that doesn't require auth to view.
*/
export function useCheckForAuthorizationTokenOnWindowFocus() {
const fn = async () => {
const isAuthenticated = await authClient.isAuthenticated();
if (isAuthenticated) {
await authClient.getTokenOrRedirect();
}
};
useEffect(() => {
window.addEventListener('focus', fn);
return () => window.removeEventListener('focus', fn);
}, []);
}
4 changes: 3 additions & 1 deletion quadratic-client/src/dashboard/FileRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { authClient } from '@/auth';
import { authClient, useCheckForAuthorizationTokenOnWindowFocus } from '@/auth';
import { CONTACT_URL } from '@/constants/urls';
import { debugShowMultiplayer } from '@/debugFlags';
import { isEmbed } from '@/helpers/isEmbed';
Expand Down Expand Up @@ -114,6 +114,8 @@ export const Component = () => {
document.querySelector('#root')?.addEventListener('wheel', (e) => e.preventDefault());
}

useCheckForAuthorizationTokenOnWindowFocus();

return (
<RecoilRoot initializeState={initializeState}>
<QuadraticApp />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { apiClient } from '@/api/apiClient';
import { useCheckForAuthorizationTokenOnWindowFocus } from '@/auth';
import { AvatarWithLetters } from '@/components/AvatarWithLetters';
import { Type } from '@/components/Type';
import { TYPE } from '@/constants/appConstants';
Expand Down Expand Up @@ -50,6 +51,9 @@ export const Component = () => {
setIsOpen((prevIsOpen) => (prevIsOpen ? false : prevIsOpen));
}, [location.pathname]);

// Ensure long-running browser sessions still have a token
useCheckForAuthorizationTokenOnWindowFocus();

return (
<div className={`h-full lg:flex lg:flex-row`}>
<div className={`hidden flex-shrink-0 border-r border-r-border lg:block`} style={{ width: drawerWidth }}>
Expand Down
2 changes: 1 addition & 1 deletion quadratic-client/src/multiplayer/multiplayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class Multiplayer {
}

private async getJwt() {
this.jwt = await authClient.getToken();
this.jwt = await authClient.getTokenOrRedirect();
}

private async addJwtCookie(force: boolean = false) {
Expand Down
16 changes: 7 additions & 9 deletions quadratic-client/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as CloudFilesMigration from './dashboard/CloudFilesMigrationRoute';
import * as Create from './dashboard/FilesCreateRoute';
import { BrowserCompatibilityLayoutRoute } from './dashboard/components/BrowserCompatibilityLayoutRoute';
import { initializeAnalytics } from './utils/analytics';

// @ts-expect-error - for testing purposes
window.lf = localforage;

Expand All @@ -41,8 +42,8 @@ export const router = createBrowserRouter(
path="/"
loader={async ({ request, params }): Promise<RootLoaderData | Response> => {
// All other routes get the same data
let isAuthenticated = await authClient.isAuthenticated();
let user = await authClient.user();
const isAuthenticated = await authClient.isAuthenticated();
const user = await authClient.user();

// This is where we determine whether we need to run a migration
// This redirect should trigger for every route _except_ the migration
Expand All @@ -54,7 +55,7 @@ export const router = createBrowserRouter(
}
}

initializeAnalytics({ isAuthenticated, user });
initializeAnalytics(user);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having a user means the person is authenticated. We don't need both.


return { isAuthenticated, loggedInUser: user };
}}
Expand Down Expand Up @@ -137,14 +138,14 @@ export const router = createBrowserRouter(
<Route
path={ROUTES.LOGIN}
loader={async ({ request }) => {
let isAuthenticated = await authClient.isAuthenticated();
const isAuthenticated = await authClient.isAuthenticated();

// If they’re authenticated, redirect home
// If they’re logged in, redirect home
if (isAuthenticated) {
return redirect('/');
}

// If they’re not authenticated, send them to Auth0
// If not, send them to Auth0
// Watch for a `from` query param, as unprotected routes will redirect
// to here for them to auth first
// Also watch for the presence of a `signup` query param, which means
Expand All @@ -156,9 +157,6 @@ export const router = createBrowserRouter(

// auth0 will re-route us (above) but telling react-router where we
// are re-routing to makes sure that this doesn't end up in the history stack
// but we have to add an artifical delay that's long enough for
// the auth0 navigation to take place
await new Promise((resolve) => setTimeout(resolve, 10000));
return redirect(redirectTo);
}}
/>
Expand Down
2 changes: 1 addition & 1 deletion quadratic-client/src/ui/menus/CodeEditor/AITab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const AITab = ({ evalResult, editorMode, editorContent, isActive }: Props
if (loading) return;
controller.current = new AbortController();
setLoading(true);
const token = await authClient.getToken();
const token = await authClient.getTokenOrRedirect();
const updatedMessages = [...messages, { role: 'user', content: prompt }] as Message[];
const request_body = {
model: 'gpt-4-32k',
Expand Down
21 changes: 9 additions & 12 deletions quadratic-client/src/utils/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
import * as amplitude from '@amplitude/analytics-browser';
import { User } from '@auth0/auth0-spa-js';
import { User as Auth0User } from '@auth0/auth0-spa-js';
import { setUser } from '@sentry/react';
import mixpanel from 'mixpanel-browser';

// Quadratic only shares analytics on the QuadraticHQ.com hosted version where the environment variables are set.

type Options = {
isAuthenticated: boolean;
user: User | undefined;
};
type User = Auth0User | undefined;

// This runs in the root loader, so analytics calls can run inside loaders.
export function initializeAnalytics({ isAuthenticated, user }: Options) {
export function initializeAnalytics(user: User) {
loadGoogleAnalytics(user);
initAmplitudeAnalytics(user);
initMixpanelAnalytics(user);
configureSentry({ isAuthenticated, user });
configureSentry(user);
}

function loadGoogleAnalytics(user: Options['user']) {
function loadGoogleAnalytics(user: User) {
if (!import.meta.env.VITE_GOOGLE_ANALYTICS_GTAG && import.meta.env.VITE_GOOGLE_ANALYTICS_GTAG !== 'none') {
return;
}
Expand Down Expand Up @@ -57,7 +54,7 @@ function loadGoogleAnalytics(user: Options['user']) {
}
}

function initAmplitudeAnalytics(user: Options['user']) {
function initAmplitudeAnalytics(user: User) {
if (
!import.meta.env.VITE_AMPLITUDE_ANALYTICS_API_KEY &&
import.meta.env.VITE_AMPLITUDE_ANALYTICS_API_KEY !== 'none'
Expand All @@ -72,7 +69,7 @@ function initAmplitudeAnalytics(user: Options['user']) {
console.log('[Analytics] Amplitude activated');
}

export function initMixpanelAnalytics(user: Options['user']) {
export function initMixpanelAnalytics(user: User) {
if (!import.meta.env.VITE_MIXPANEL_ANALYTICS_KEY && import.meta.env.VITE_MIXPANEL_ANALYTICS_KEY !== 'none') {
// Without init Mixpanel, all mixpanel events throw an error and break the app.
// So we have to init Mixpanel with a fake key, and disable Mixpanel.
Expand Down Expand Up @@ -102,8 +99,8 @@ export function initMixpanelAnalytics(user: Options['user']) {
console.log('[Analytics] Mixpanel activated');
}

function configureSentry({ isAuthenticated, user }: Options) {
if (isAuthenticated && user) {
function configureSentry(user: User) {
if (user) {
setUser({ email: user.email, id: user.sub });
console.log('[Analytics] Sentry user set');
}
Expand Down
Loading