Refresh-token auth flow working. Still cannot clear search.

This commit is contained in:
Jesse Brault 2024-08-07 10:34:11 -05:00
parent 85f6913b0e
commit 3ea7f6f83a
4 changed files with 81 additions and 100 deletions

View File

@ -3,35 +3,49 @@ import {
QueryClient,
QueryClientProvider
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useLocation, useNavigate } from '@tanstack/react-router'
import React, { useState } from 'react'
import ExpiredTokenError from './api/ExpiredTokenError'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import refresh, { ExpiredRefreshTokenError } from './api/refresh'
import LoginView from './api/types/LoginView'
import { useAuth } from './auth'
import refresh from './api/refresh'
import { useNavigate } from '@tanstack/react-router'
const AuthAwareQueryClientProvider = ({
children
}: React.PropsWithChildren) => {
const { putToken } = useAuth()
const { putToken, clearToken } = useAuth()
const navigate = useNavigate()
const [currentlyRefreshing, setCurrentlyRefreshing] = useState(false)
const { href } = useLocation()
const doRefresh = async () => {
if (!currentlyRefreshing) {
console.log('starting refresh')
setCurrentlyRefreshing(true)
const refreshResult = await refresh()
if (refreshResult._tag === 'success') {
console.log('refresh success, putting token...')
putToken(
refreshResult.loginView.accessToken,
refreshResult.loginView.username
)
} else {
console.error(`refresh failure: ${refreshResult.error}`)
navigate({ to: '/login' }) // not working
let refreshResult: LoginView
try {
refreshResult = await refresh()
} catch (error) {
if (error instanceof ExpiredRefreshTokenError) {
console.log('refresh-token expired')
setCurrentlyRefreshing(false)
clearToken()
await navigate({
to: '/login',
search: {
expired: true,
redirect: href
}
})
console.log('post-navigate')
return
} else {
setCurrentlyRefreshing(false)
throw error
}
}
putToken(refreshResult.accessToken, refreshResult.username)
setCurrentlyRefreshing(false)
console.log('refresh done')
}

View File

@ -1,12 +1,11 @@
import { notFound, redirect } from '@tanstack/react-router'
import { notFound } from '@tanstack/react-router'
import { AuthContextType } from '../auth'
import { ApiError } from './ApiError'
import ExpiredTokenError from './ExpiredTokenError'
import FullRecipeView, {
RawFullRecipeView,
toFullRecipeView
} from './types/FullRecipeView'
import LoginView from './types/LoginView'
import SecurityExceptionView from './types/SecurityExceptionView'
export interface GetRecipeDeps {
authContext: AuthContextType
@ -15,10 +14,12 @@ export interface GetRecipeDeps {
abortSignal: AbortSignal
}
const getRecipe = async (
{ authContext, username, slug, abortSignal }: GetRecipeDeps,
isRetry: boolean = false
): Promise<FullRecipeView> => {
const getRecipe = async ({
authContext,
username,
slug,
abortSignal
}: GetRecipeDeps): Promise<FullRecipeView> => {
const headers = new Headers()
if (authContext.token !== null) {
headers.set('Authorization', `Bearer ${authContext.token}`)
@ -33,42 +34,8 @@ const getRecipe = async (
)
if (response.ok) {
return toFullRecipeView((await response.json()) as RawFullRecipeView)
} else if (response.status === 401 && !isRetry) {
// must be logged in to view this resource
const securityExceptionView =
(await response.json()) as SecurityExceptionView
if (securityExceptionView.action === 'REFRESH') {
// do refresh
const refreshResponse = await fetch(
import.meta.env.VITE_MME_API_URL + '/auth/refresh',
{
signal: abortSignal,
method: 'POST'
}
)
if (refreshResponse.ok) {
const { accessToken, username } =
(await refreshResponse.json()) as LoginView
authContext.putToken(accessToken, username)
return getRecipe(
{
abortSignal,
authContext,
slug,
username
},
true
)
} else {
throw new ApiError(
refreshResponse.status,
refreshResponse.statusText
)
}
} else {
// do login
throw redirect({ to: '/login' })
}
} else if (response.status === 401) {
throw new ExpiredTokenError()
} else if (response.status === 404) {
// no such resource
throw notFound()

View File

@ -1,8 +1,17 @@
import { LoginResult, RawLoginView } from './types/LoginView'
import { ApiError } from './ApiError'
import LoginView, { RawLoginView } from './types/LoginView'
const refresh = async (): Promise<LoginResult> => {
export class ExpiredRefreshTokenError extends ApiError {
constructor() {
super(401, 'Expired refresh token.')
Object.setPrototypeOf(this, ExpiredRefreshTokenError.prototype)
}
}
const refresh = async (): Promise<LoginView> => {
let response: Response
try {
const response = await fetch(
response = await fetch(
import.meta.env.VITE_MME_API_URL + '/auth/refresh',
{
credentials: 'include',
@ -10,45 +19,29 @@ const refresh = async (): Promise<LoginResult> => {
mode: 'cors'
}
)
if (response.ok) {
const {
username,
accessToken,
expires: rawExpires
} = (await response.json()) as RawLoginView
return {
_tag: 'success',
loginView: {
username,
accessToken,
expires: new Date(rawExpires)
}
}
} else {
let error: string
if (response.status === 401) {
error = 'Invalid username or password.'
} else if (response.status === 500) {
error =
'There was an internal server error. Please try again later.'
} else {
error = 'Unknown error.'
console.error(
`Unknown error: ${response.status} ${response.statusText}`
)
}
return {
_tag: 'failure',
error
}
}
} catch (fetchError) {
console.error(`Unknown fetch error: ${fetchError}`)
return {
_tag: 'failure',
error: 'Network error. Please try again later.'
if (fetchError instanceof TypeError) {
throw fetchError // rethrow network issues
} else {
throw new Error(`Unknown fetch error: ${fetchError}`)
}
}
if (response.ok) {
const {
username,
accessToken,
expires: rawExpires
} = (await response.json()) as RawLoginView
return {
username,
accessToken,
expires: new Date(rawExpires)
}
} else if (response.status === 401) {
throw new ExpiredRefreshTokenError()
} else {
throw new ApiError(response.status, response.statusText)
}
}
export default refresh

View File

@ -16,7 +16,7 @@ const Login = () => {
const router = useRouter()
const navigate = useNavigate()
const search = useSearch({ from: '/login' })
const { redirect, expired } = useSearch({ from: '/login' })
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
@ -30,7 +30,10 @@ const Login = () => {
loginResult.loginView.username,
async () => {
await router.invalidate()
await navigate({ to: search.redirect ?? '/recipes' })
await navigate({
to: redirect ?? '/recipes',
search: {}
})
}
)
} else {
@ -41,6 +44,9 @@ const Login = () => {
return (
<div>
<h2>Login Page</h2>
{expired ? (
<p>Your session has expired. Please login again.</p>
) : null}
<form onSubmit={onSubmit}>
<label htmlFor="username">Username</label>
<input id="username" name="username" type="text" />
@ -58,11 +64,12 @@ const Login = () => {
export const Route = createFileRoute('/login')({
validateSearch: z.object({
expired: z.boolean().optional().catch(false),
redirect: z.string().optional().catch('')
}),
beforeLoad({ context, search }) {
if (context.auth.token) {
throw redirect({ to: search.redirect || '/recipes' })
if (!(search.expired || context.auth.token === null)) {
throw redirect({ to: '/recipes' })
}
},
component: Login