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, QueryClient,
QueryClientProvider QueryClientProvider
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useLocation, useNavigate } from '@tanstack/react-router'
import React, { useState } from 'react' import React, { useState } from 'react'
import ExpiredTokenError from './api/ExpiredTokenError' 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 { useAuth } from './auth'
import refresh from './api/refresh'
import { useNavigate } from '@tanstack/react-router'
const AuthAwareQueryClientProvider = ({ const AuthAwareQueryClientProvider = ({
children children
}: React.PropsWithChildren) => { }: React.PropsWithChildren) => {
const { putToken } = useAuth() const { putToken, clearToken } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [currentlyRefreshing, setCurrentlyRefreshing] = useState(false) const [currentlyRefreshing, setCurrentlyRefreshing] = useState(false)
const { href } = useLocation()
const doRefresh = async () => { const doRefresh = async () => {
if (!currentlyRefreshing) { if (!currentlyRefreshing) {
console.log('starting refresh') console.log('starting refresh')
setCurrentlyRefreshing(true) setCurrentlyRefreshing(true)
const refreshResult = await refresh() let refreshResult: LoginView
if (refreshResult._tag === 'success') { try {
console.log('refresh success, putting token...') refreshResult = await refresh()
putToken( } catch (error) {
refreshResult.loginView.accessToken, if (error instanceof ExpiredRefreshTokenError) {
refreshResult.loginView.username console.log('refresh-token expired')
) setCurrentlyRefreshing(false)
} else { clearToken()
console.error(`refresh failure: ${refreshResult.error}`) await navigate({
navigate({ to: '/login' }) // not working to: '/login',
search: {
expired: true,
redirect: href
} }
})
console.log('post-navigate')
return
} else {
setCurrentlyRefreshing(false)
throw error
}
}
putToken(refreshResult.accessToken, refreshResult.username)
setCurrentlyRefreshing(false) setCurrentlyRefreshing(false)
console.log('refresh done') 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 { AuthContextType } from '../auth'
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import ExpiredTokenError from './ExpiredTokenError'
import FullRecipeView, { import FullRecipeView, {
RawFullRecipeView, RawFullRecipeView,
toFullRecipeView toFullRecipeView
} from './types/FullRecipeView' } from './types/FullRecipeView'
import LoginView from './types/LoginView'
import SecurityExceptionView from './types/SecurityExceptionView'
export interface GetRecipeDeps { export interface GetRecipeDeps {
authContext: AuthContextType authContext: AuthContextType
@ -15,10 +14,12 @@ export interface GetRecipeDeps {
abortSignal: AbortSignal abortSignal: AbortSignal
} }
const getRecipe = async ( const getRecipe = async ({
{ authContext, username, slug, abortSignal }: GetRecipeDeps, authContext,
isRetry: boolean = false username,
): Promise<FullRecipeView> => { slug,
abortSignal
}: GetRecipeDeps): Promise<FullRecipeView> => {
const headers = new Headers() const headers = new Headers()
if (authContext.token !== null) { if (authContext.token !== null) {
headers.set('Authorization', `Bearer ${authContext.token}`) headers.set('Authorization', `Bearer ${authContext.token}`)
@ -33,42 +34,8 @@ const getRecipe = async (
) )
if (response.ok) { if (response.ok) {
return toFullRecipeView((await response.json()) as RawFullRecipeView) return toFullRecipeView((await response.json()) as RawFullRecipeView)
} else if (response.status === 401 && !isRetry) { } else if (response.status === 401) {
// must be logged in to view this resource throw new ExpiredTokenError()
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 === 404) { } else if (response.status === 404) {
// no such resource // no such resource
throw notFound() 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 { try {
const response = await fetch( response = await fetch(
import.meta.env.VITE_MME_API_URL + '/auth/refresh', import.meta.env.VITE_MME_API_URL + '/auth/refresh',
{ {
credentials: 'include', credentials: 'include',
@ -10,6 +19,13 @@ const refresh = async (): Promise<LoginResult> => {
mode: 'cors' mode: 'cors'
} }
) )
} catch (fetchError) {
if (fetchError instanceof TypeError) {
throw fetchError // rethrow network issues
} else {
throw new Error(`Unknown fetch error: ${fetchError}`)
}
}
if (response.ok) { if (response.ok) {
const { const {
username, username,
@ -17,37 +33,14 @@ const refresh = async (): Promise<LoginResult> => {
expires: rawExpires expires: rawExpires
} = (await response.json()) as RawLoginView } = (await response.json()) as RawLoginView
return { return {
_tag: 'success',
loginView: {
username, username,
accessToken, accessToken,
expires: new Date(rawExpires) expires: new Date(rawExpires)
} }
} } else if (response.status === 401) {
throw new ExpiredRefreshTokenError()
} else { } else {
let error: string throw new ApiError(response.status, response.statusText)
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.'
}
} }
} }

View File

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