Refresh-token auth flow working. Still cannot clear search.
This commit is contained in:
parent
85f6913b0e
commit
3ea7f6f83a
@ -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')
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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,45 +19,29 @@ const refresh = async (): Promise<LoginResult> => {
|
|||||||
mode: 'cors'
|
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) {
|
} catch (fetchError) {
|
||||||
console.error(`Unknown fetch error: ${fetchError}`)
|
if (fetchError instanceof TypeError) {
|
||||||
return {
|
throw fetchError // rethrow network issues
|
||||||
_tag: 'failure',
|
} else {
|
||||||
error: 'Network error. Please try again later.'
|
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
|
export default refresh
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user