Skip to content

Commit

Permalink
UI feat: attempt to refresh auth token on forbidden requests
Browse files Browse the repository at this point in the history
Signed-off-by: Ryan Hopper-Lowe <[email protected]>
  • Loading branch information
ryanhopperlowe committed Nov 5, 2024
1 parent e22c7b9 commit 7719623
Show file tree
Hide file tree
Showing 7 changed files with 79 additions and 29 deletions.
8 changes: 5 additions & 3 deletions ui/admin/app/components/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { ReactNode, createContext, useContext } from "react";
import useSWR from "swr";

import { AuthDisabledUsername } from "~/lib/model/auth";
import { Role, User } from "~/lib/model/users";
import { UserService } from "~/lib/service/api/userService";

export const AuthDisabledUsername = "nobody";

interface AuthContextType {
me: User;
isLoading: boolean;
isSignedIn: boolean;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);
Expand All @@ -20,8 +20,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
{ fallbackData: { role: Role.Default } as User }
);

const isSignedIn = !!me.username && me.username !== AuthDisabledUsername;

return (
<AuthContext.Provider value={{ me, isLoading }}>
<AuthContext.Provider value={{ me, isLoading, isSignedIn }}>
{children}
</AuthContext.Provider>
);
Expand Down
3 changes: 2 additions & 1 deletion ui/admin/app/components/user/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { User } from "lucide-react";
import React from "react";

import { AuthDisabledUsername } from "~/lib/model/auth";
import { roleToString } from "~/lib/model/users";
import { cn } from "~/lib/utils";

import { AuthDisabledUsername, useAuth } from "~/components/auth/AuthContext";
import { useAuth } from "~/components/auth/AuthContext";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import {
Expand Down
10 changes: 7 additions & 3 deletions ui/admin/app/hooks/useAsync.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { AxiosError } from "axios";
import { useCallback, useState } from "react";

import { handlePromise } from "~/lib/service/async";
import { noop } from "~/lib/utils";

type Config<TData, TParams extends unknown[]> = {
onSuccess?: (data: TData, params: TParams) => void;
onError?: (error: unknown, params: TParams) => void;
onSettled?: ({ params }: { params: TParams }) => void;
};

const defaultShouldThrow = (error: unknown) => !(error instanceof AxiosError);

export function useAsync<TData, TParams extends unknown[]>(
callback: (...params: TParams) => Promise<TData>,
config?: Config<TData, TParams>
Expand All @@ -20,6 +22,8 @@ export function useAsync<TData, TParams extends unknown[]>(
const [isLoading, setIsLoading] = useState(false);
const [lastCallParams, setLastCallParams] = useState<TParams | null>(null);

if (error && defaultShouldThrow(error)) throw error;

const executeAsync = useCallback(
async (...params: TParams) => {
setIsLoading(true);
Expand All @@ -41,14 +45,14 @@ export function useAsync<TData, TParams extends unknown[]>(
onSettled?.({ params });
});

return handlePromise(promise);
return await handlePromise(promise);
},
[callback, onSuccess, onError, onSettled]
);

const execute = useCallback(
(...params: TParams) => {
executeAsync(...params).catch(noop);
executeAsync(...params);
},
[executeAsync]
);
Expand Down
1 change: 1 addition & 0 deletions ui/admin/app/lib/model/auth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const AuthDisabledUsername = "nobody";
2 changes: 2 additions & 0 deletions ui/admin/app/lib/service/api/apiErrors.tsx
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export class ConflictError extends Error {}
export class ForbiddenError extends Error {}
export class UnauthorizedError extends Error {}
37 changes: 36 additions & 1 deletion ui/admin/app/lib/service/api/primitives.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// TODO: Add default configurations with auth tokens, etc. When ready
import axios, { AxiosRequestConfig, AxiosResponse, isAxiosError } from "axios";

import { ConflictError } from "~/lib/service/api/apiErrors";
import { AuthDisabledUsername } from "~/lib/model/auth";
import { User } from "~/lib/model/users";
import { ApiRoutes } from "~/lib/routers/apiRoutes";
import {
ConflictError,
ForbiddenError,
UnauthorizedError,
} from "~/lib/service/api/apiErrors";

export const ResponseHeaders = {
ThreadId: "x-otto-thread-id",
Expand All @@ -12,10 +19,12 @@ const internalFetch = axios.request;
interface ExtendedAxiosRequestConfig<D = unknown>
extends AxiosRequestConfig<D> {
errorMessage?: string;
disableTokenRefresh?: boolean;
}

export async function request<T, R = AxiosResponse<T>, D = unknown>({
errorMessage = "Request failed",
disableTokenRefresh,
...config
}: ExtendedAxiosRequestConfig<D>): Promise<R> {
try {
Expand All @@ -26,6 +35,32 @@ export async function request<T, R = AxiosResponse<T>, D = unknown>({
} catch (error) {
console.error(errorMessage);

if (isAxiosError(error) && error.response?.status === 401) {
throw new UnauthorizedError(error.response.data);
}

if (isAxiosError(error) && error.response?.status === 403) {
// Tokens are automatically refreshed on GET requests
if (disableTokenRefresh) {
throw new ForbiddenError(error.response.data);
}

console.info("Forbidden request, attempting to refresh token");
const { data } = await internalFetch<User>({
url: ApiRoutes.me().url,
});

// if token is refreshed successfully, retry the request
if (!data?.username || data.username === AuthDisabledUsername)
throw new ForbiddenError(error.response.data);

console.info("Token refreshed");
return request<T, R, D>({
...config,
disableTokenRefresh: true,
});
}

if (isAxiosError(error) && error.response?.status === 409) {
throw new ConflictError(error.response.data);
}
Expand Down
47 changes: 26 additions & 21 deletions ui/admin/app/routes/_auth.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@
import { Outlet, isRouteErrorResponse, useRouteError } from "@remix-run/react";
import { AxiosError } from "axios";
import { preload } from "swr";

import { AuthDisabledUsername, useAuth } from "~/components/auth/AuthContext";
import { ForbiddenError, UnauthorizedError } from "~/lib/service/api/apiErrors";
import { UserService } from "~/lib/service/api/userService";

import { useAuth } from "~/components/auth/AuthContext";
import { Error, RouteError, Unauthorized } from "~/components/errors";
import { HeaderNav } from "~/components/header/HeaderNav";
import { Sidebar } from "~/components/sidebar";
import { SignIn } from "~/components/signin/SignIn";

export function ErrorBoundary() {
const error = useRouteError();
const { me } = useAuth();

switch (true) {
case error instanceof AxiosError:
if (
error.response?.status === 403 &&
me.username &&
me.username !== AuthDisabledUsername
) {
return <Unauthorized />;
}
return <SignIn />;
case isRouteErrorResponse(error):
return <RouteError error={error} />;
default:
return <Error error={error as Error} />;
}
export async function clientLoader() {
const me = await preload(UserService.getMe.key(), () =>
UserService.getMe()
);
return { me };
}

export default function AuthLayout() {
Expand All @@ -41,3 +30,19 @@ export default function AuthLayout() {
</div>
);
}

export function ErrorBoundary() {
const error = useRouteError();
const { isSignedIn } = useAuth();

switch (true) {
case error instanceof UnauthorizedError:
case error instanceof ForbiddenError:
if (isSignedIn) return <Unauthorized />;
else return <SignIn />;
case isRouteErrorResponse(error):
return <RouteError error={error} />;
default:
return <Error error={error as Error} />;
}
}

0 comments on commit 7719623

Please sign in to comment.