diff --git a/client/src/components/login-form.tsx b/client/src/components/login-form.tsx index 4ceaa92..8ef955b 100644 --- a/client/src/components/login-form.tsx +++ b/client/src/components/login-form.tsx @@ -1,4 +1,5 @@ import type { TurnstileInstance } from '@marsidev/react-turnstile'; +import type { AuthorizeSearchParams } from '@/routes/authorize'; import { Turnstile } from '@marsidev/react-turnstile'; import { useNavigate } from '@tanstack/react-router'; import { useRef, useState } from 'react'; @@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink'; import { cn } from '@/lib/utils'; export function LoginForm({ + oauthParams, className, ...props -}: React.ComponentProps<'div'>) { +}: React.ComponentProps<'div'> & { + oauthParams: AuthorizeSearchParams; +}) { const formRef = useRef(null); const turnstileRef = useRef(null); const [token, setToken] = useState(null); @@ -28,7 +32,7 @@ export function LoginForm({ event.preventDefault(); const formData = new FormData(formRef.current!); const email = formData.get('email')! as string; - mutateAsync({ email, turnstile_token: token! }).then(() => { + mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => { void navigate({ to: '/magicLinkSent', search: { email } }); }).catch((error) => { console.error(error); diff --git a/client/src/hooks/data/useGetMagicLink.ts b/client/src/hooks/data/useGetMagicLink.ts index 4f2c7c3..4be1b04 100644 --- a/client/src/hooks/data/useGetMagicLink.ts +++ b/client/src/hooks/data/useGetMagicLink.ts @@ -1,7 +1,8 @@ +import type { AuthorizeSearchParams } from '@/routes/authorize'; import { useMutation } from '@tanstack/react-query'; import { axiosClient } from '@/lib/axios'; -interface GetMagicLinkPayload { +interface GetMagicLinkPayload extends AuthorizeSearchParams { email: string; turnstile_token: string; } @@ -9,7 +10,7 @@ interface GetMagicLinkPayload { export function useGetMagicLink() { return useMutation({ mutationFn: async (payload: GetMagicLinkPayload) => { - return axiosClient.post('/auth/magic', payload); + return axiosClient.post<{ status: string }>('/auth/magic', payload); }, }); } diff --git a/client/src/hooks/useLogout.ts b/client/src/hooks/useLogout.ts index d2bcdf8..e8f731c 100644 --- a/client/src/hooks/useLogout.ts +++ b/client/src/hooks/useLogout.ts @@ -7,7 +7,7 @@ export function useLogout() { const logout = useCallback(() => { clearTokens(); - void navigate({ to: '/login' }); + void navigate({ to: '/authorize' }); }, [navigate]); return { logout }; diff --git a/client/src/lib/random.ts b/client/src/lib/random.ts new file mode 100644 index 0000000..b2cd6a0 --- /dev/null +++ b/client/src/lib/random.ts @@ -0,0 +1,14 @@ +/** + * Generate a cryptographically secure OAuth2 state string + * base64url encoded, URL-safe + */ +export function generateOAuthState(bytes: number = 32): string { + const random = new Uint8Array(bytes); + crypto.getRandomValues(random); + + // base64url encode + return btoa(String.fromCharCode(...random)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} diff --git a/client/src/lib/token.ts b/client/src/lib/token.ts index f4f9bbb..67b9a2b 100644 --- a/client/src/lib/token.ts +++ b/client/src/lib/token.ts @@ -29,6 +29,12 @@ export function clearTokens() { setRefreshToken(''); } +export async function doSetTokenByCode(code: string) { + const { data } = await axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/token', { code }); + setToken(data.access_token); + setRefreshToken(data.refresh_token); +} + export async function doRefreshToken() { return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() }); } diff --git a/client/src/routeTree.gen.ts b/client/src/routeTree.gen.ts index dcada6a..205fd38 100644 --- a/client/src/routeTree.gen.ts +++ b/client/src/routeTree.gen.ts @@ -9,20 +9,26 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as TokenRouteImport } from './routes/token' import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent' -import { Route as LoginRouteImport } from './routes/login' +import { Route as AuthorizeRouteImport } from './routes/authorize' import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout' import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index' import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile' +const TokenRoute = TokenRouteImport.update({ + id: '/token', + path: '/token', + getParentRoute: () => rootRouteImport, +} as any) const MagicLinkSentRoute = MagicLinkSentRouteImport.update({ id: '/magicLinkSent', path: '/magicLinkSent', getParentRoute: () => rootRouteImport, } as any) -const LoginRoute = LoginRouteImport.update({ - id: '/login', - path: '/login', +const AuthorizeRoute = AuthorizeRouteImport.update({ + id: '/authorize', + path: '/authorize', getParentRoute: () => rootRouteImport, } as any) const SidebarLayoutRoute = SidebarLayoutRouteImport.update({ @@ -41,47 +47,59 @@ const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({ } as any) export interface FileRoutesByFullPath { - '/login': typeof LoginRoute + '/authorize': typeof AuthorizeRoute '/magicLinkSent': typeof MagicLinkSentRoute + '/token': typeof TokenRoute '/profile': typeof SidebarLayoutProfileRoute '/': typeof SidebarLayoutIndexRoute } export interface FileRoutesByTo { - '/login': typeof LoginRoute + '/authorize': typeof AuthorizeRoute '/magicLinkSent': typeof MagicLinkSentRoute + '/token': typeof TokenRoute '/profile': typeof SidebarLayoutProfileRoute '/': typeof SidebarLayoutIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_sidebarLayout': typeof SidebarLayoutRouteWithChildren - '/login': typeof LoginRoute + '/authorize': typeof AuthorizeRoute '/magicLinkSent': typeof MagicLinkSentRoute + '/token': typeof TokenRoute '/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute '/_sidebarLayout/': typeof SidebarLayoutIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/login' | '/magicLinkSent' | '/profile' | '/' + fullPaths: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/' fileRoutesByTo: FileRoutesByTo - to: '/login' | '/magicLinkSent' | '/profile' | '/' + to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/' id: | '__root__' | '/_sidebarLayout' - | '/login' + | '/authorize' | '/magicLinkSent' + | '/token' | '/_sidebarLayout/profile' | '/_sidebarLayout/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren - LoginRoute: typeof LoginRoute + AuthorizeRoute: typeof AuthorizeRoute MagicLinkSentRoute: typeof MagicLinkSentRoute + TokenRoute: typeof TokenRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/token': { + id: '/token' + path: '/token' + fullPath: '/token' + preLoaderRoute: typeof TokenRouteImport + parentRoute: typeof rootRouteImport + } '/magicLinkSent': { id: '/magicLinkSent' path: '/magicLinkSent' @@ -89,11 +107,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MagicLinkSentRouteImport parentRoute: typeof rootRouteImport } - '/login': { - id: '/login' - path: '/login' - fullPath: '/login' - preLoaderRoute: typeof LoginRouteImport + '/authorize': { + id: '/authorize' + path: '/authorize' + fullPath: '/authorize' + preLoaderRoute: typeof AuthorizeRouteImport parentRoute: typeof rootRouteImport } '/_sidebarLayout': { @@ -136,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { SidebarLayoutRoute: SidebarLayoutRouteWithChildren, - LoginRoute: LoginRoute, + AuthorizeRoute: AuthorizeRoute, MagicLinkSentRoute: MagicLinkSentRoute, + TokenRoute: TokenRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/client/src/routes/_sidebarLayout/index.tsx b/client/src/routes/_sidebarLayout/index.tsx index 9a8b7d8..da2d107 100644 --- a/client/src/routes/_sidebarLayout/index.tsx +++ b/client/src/routes/_sidebarLayout/index.tsx @@ -7,7 +7,7 @@ export const Route = createFileRoute('/_sidebarLayout/')({ loader: async () => { if (!hasToken()) { throw redirect({ - to: '/login', + to: '/authorize', }); } }, diff --git a/client/src/routes/authorize.tsx b/client/src/routes/authorize.tsx new file mode 100644 index 0000000..3239bbe --- /dev/null +++ b/client/src/routes/authorize.tsx @@ -0,0 +1,42 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; +import { LoginForm } from '@/components/login-form'; +import { generateOAuthState } from '@/lib/random'; +import { getToken } from '@/lib/token'; + +const authorizeSchema = z.object({ + response_type: z.literal('code').default('code'), + client_id: z.literal('org_client').default('org_client'), + redirect_uri: z.string().default(`${new URL(import.meta.env.VITE_APP_BASE_URL as string).toString()}token`), + state: z.string().default(generateOAuthState()), +}); + +export type AuthorizeSearchParams = z.infer; + +export const Route = createFileRoute('/authorize')({ + component: RouteComponent, + validateSearch: zodValidator(authorizeSchema), +}); + +function RouteComponent() { + const token = getToken(); + const oauthParams = Route.useSearch(); + if (token !== null) { + const base = new URL(window.location.origin); + const url = new URL('/api/v1/auth/redirect', base); + url.searchParams.set('client_id', oauthParams.client_id); + url.searchParams.set('response_type', oauthParams.response_type); + url.searchParams.set('redirect_uri', oauthParams.redirect_uri); + url.searchParams.set('state', oauthParams.state); + window.location.href = url.toString(); + return null; + } + return ( +
+
+ +
+
+ ); +} diff --git a/client/src/routes/login.tsx b/client/src/routes/login.tsx deleted file mode 100644 index fd75476..0000000 --- a/client/src/routes/login.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { createFileRoute, Navigate } from '@tanstack/react-router'; -import { zodValidator } from '@tanstack/zod-adapter'; -import z from 'zod'; -import { LoginForm } from '@/components/login-form'; -import { useValidateMagicLink } from '@/hooks/data/useValidateMagicLink'; -import { setRefreshToken, setToken } from '@/lib/token'; - -const loginMagicLinkReceiverSchema = z.object({ - ticket: z.string().optional(), -}); - -export const Route = createFileRoute('/login')({ - component: RouteComponent, - validateSearch: zodValidator(loginMagicLinkReceiverSchema), -}); - -function ReceiveMagicLinkComponent() { - const { ticket } = Route.useSearch(); - const { data } = useValidateMagicLink(ticket!); - - setToken(data.data.access_token); - setRefreshToken(data.data.refresh_token); - - return ; -} - -function RouteComponent() { - const { ticket } = Route.useSearch(); - return ( -
-
- {ticket === undefined ? : } -
-
- ); -} diff --git a/client/src/routes/token.tsx b/client/src/routes/token.tsx new file mode 100644 index 0000000..fbc0278 --- /dev/null +++ b/client/src/routes/token.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import z from 'zod'; +import { doSetTokenByCode } from '@/lib/token'; + +const tokenCodeSchema = z.object({ + code: z.string().nonempty(), +}); + +export const Route = createFileRoute('/token')({ + component: RouteComponent, + validateSearch: tokenCodeSchema, +}); + +function RouteComponent() { + const { code } = Route.useSearch(); + const [status, setStatus] = useState('Loading...'); + const navigate = useNavigate(); + doSetTokenByCode(code).then(() => { + void navigate({ to: '/' }); + }).catch((_) => { + setStatus('Error getting token'); + }); + return
{status}
; +}