Refresh-token auth flow working. Still cannot clear search.
This commit is contained in:
parent
85f6913b0e
commit
3ea7f6f83a
@ -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')
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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,6 +19,13 @@ const refresh = async (): Promise<LoginResult> => {
|
||||
mode: 'cors'
|
||||
}
|
||||
)
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof TypeError) {
|
||||
throw fetchError // rethrow network issues
|
||||
} else {
|
||||
throw new Error(`Unknown fetch error: ${fetchError}`)
|
||||
}
|
||||
}
|
||||
if (response.ok) {
|
||||
const {
|
||||
username,
|
||||
@ -17,37 +33,14 @@ const refresh = async (): Promise<LoginResult> => {
|
||||
expires: rawExpires
|
||||
} = (await response.json()) as RawLoginView
|
||||
return {
|
||||
_tag: 'success',
|
||||
loginView: {
|
||||
username,
|
||||
accessToken,
|
||||
expires: new Date(rawExpires)
|
||||
}
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
throw new ExpiredRefreshTokenError()
|
||||
} 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.'
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user