From e3be74d5191752b13fc26279e9670d732136c4ab Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 10 Mar 2023 16:00:02 +0900 Subject: [PATCH] Handle token refresh on SSR --- .../Controllers/PermissionValidator.cs | 6 +- back/src/Kyoo.Authentication/Views/AuthApi.cs | 8 +- front/apps/web/src/pages/_app.tsx | 13 +++- front/packages/models/src/login.ts | 35 ++++++--- front/packages/models/src/query.tsx | 73 +++++++++---------- front/packages/models/src/secure-store.web.ts | 9 ++- front/packages/models/src/utils.ts | 12 +-- front/packages/ui/src/login/login.tsx | 6 +- front/packages/ui/src/login/register.tsx | 6 +- 9 files changed, 90 insertions(+), 78 deletions(-) diff --git a/back/src/Kyoo.Authentication/Controllers/PermissionValidator.cs b/back/src/Kyoo.Authentication/Controllers/PermissionValidator.cs index 370a02f3..77aacbfc 100644 --- a/back/src/Kyoo.Authentication/Controllers/PermissionValidator.cs +++ b/back/src/Kyoo.Authentication/Controllers/PermissionValidator.cs @@ -29,7 +29,6 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Kyoo.Authentication @@ -181,7 +180,10 @@ namespace Kyoo.Authentication { ICollection permissions = _options.CurrentValue.Default ?? Array.Empty(); if (res.Failure != null || permissions.All(x => x != permStr && x != overallStr)) - context.Result = _ErrorResult($"Unlogged user does not have permission {permStr} or {overallStr}", StatusCodes.Status401Unauthorized); + { + context.Result = _ErrorResult("Token non present or invalid (it may have expired). " + + $"Unlogged user does not have permission {permStr} or {overallStr}", StatusCodes.Status401Unauthorized); + } } } } diff --git a/back/src/Kyoo.Authentication/Views/AuthApi.cs b/back/src/Kyoo.Authentication/Views/AuthApi.cs index bab8e0bb..a698ce37 100644 --- a/back/src/Kyoo.Authentication/Views/AuthApi.cs +++ b/back/src/Kyoo.Authentication/Views/AuthApi.cs @@ -197,7 +197,7 @@ namespace Kyoo.Authentication.Views public async Task> GetMe() { if (!int.TryParse(User.FindFirstValue(Claims.Id), out int userID)) - return Unauthorized(new RequestError("User not authenticated")); + return Unauthorized(new RequestError("User not authenticated or token invalid.")); try { return await _users.Get(userID); @@ -226,7 +226,7 @@ namespace Kyoo.Authentication.Views public async Task> EditMe(User user) { if (!int.TryParse(User.FindFirstValue(Claims.Id), out int userID)) - return Unauthorized(new RequestError("User not authenticated")); + return Unauthorized(new RequestError("User not authenticated or token invalid.")); try { user.ID = userID; @@ -256,7 +256,7 @@ namespace Kyoo.Authentication.Views public async Task> PatchMe(User user) { if (!int.TryParse(User.FindFirstValue(Claims.Id), out int userID)) - return Unauthorized(new RequestError("User not authenticated")); + return Unauthorized(new RequestError("User not authenticated or token invalid.")); try { user.ID = userID; @@ -285,7 +285,7 @@ namespace Kyoo.Authentication.Views public async Task> DeleteMe() { if (!int.TryParse(User.FindFirstValue(Claims.Id), out int userID)) - return Unauthorized(new RequestError("User not authenticated")); + return Unauthorized(new RequestError("User not authenticated or token invalid.")); try { await _users.Delete(userID); diff --git a/front/apps/web/src/pages/_app.tsx b/front/apps/web/src/pages/_app.tsx index 8cd2ac31..a9fa9d40 100755 --- a/front/apps/web/src/pages/_app.tsx +++ b/front/apps/web/src/pages/_app.tsx @@ -22,7 +22,8 @@ import "../polyfill"; import { Hydrate, QueryClientProvider } from "@tanstack/react-query"; import { HiddenIfNoJs, SkeletonCss, ThemeSelector, WebTooltip } from "@kyoo/primitives"; -import { createQueryClient, fetchQuery, QueryIdentifier, QueryPage } from "@kyoo/models"; +import { createQueryClient, fetchQuery, getTokenWJ, QueryIdentifier, QueryPage } from "@kyoo/models"; +import { setSecureItemSync } from "@kyoo/models/src/secure-store.web"; import { useState } from "react"; import NextApp, { AppContext, type AppProps } from "next/app"; import { Poppins } from "@next/font/google"; @@ -88,13 +89,17 @@ const YoshikiDebug = ({ children }: { children: JSX.Element }) => { const App = ({ Component, pageProps }: AppProps) => { const [queryClient] = useState(() => createQueryClient()); - const { queryState, ...props } = superjson.deserialize(pageProps ?? { json: {} }); + const { queryState, token, ...props } = superjson.deserialize(pageProps ?? { json: {} }); const layoutInfo = (Component as QueryPage).getLayout ?? (({ page }) => page); const { Layout, props: layoutProps } = typeof layoutInfo === "function" ? { Layout: layoutInfo, props: {} } : layoutInfo; useMobileHover(); + // Set the auth from the server (if the token was refreshed during SSR). + if (typeof window !== "undefined" && token) + setSecureItemSync("auth", JSON.stringify(token)); + return ( <> @@ -124,7 +129,9 @@ App.getInitialProps = async (ctx: AppContext) => { ...(getUrl ? getUrl(ctx.router.query as any) : []), ...(getLayoutUrl ? getLayoutUrl(ctx.router.query as any) : []), ]; - appProps.pageProps.queryState = await fetchQuery(urls, ctx.ctx.req?.headers.cookie); + const [authToken, token] = await getTokenWJ(ctx.ctx.req?.headers.cookie); + appProps.pageProps.queryState = await fetchQuery(urls, authToken); + appProps.pageProps.token = token; return { pageProps: superjson.serialize(appProps.pageProps) }; }; diff --git a/front/packages/models/src/login.ts b/front/packages/models/src/login.ts index 23aefec8..cad71fd4 100644 --- a/front/packages/models/src/login.ts +++ b/front/packages/models/src/login.ts @@ -33,38 +33,49 @@ const TokenP = z.object({ }); type Token = z.infer; +type Result = + | { ok: true; value: A; error?: undefined } + | { ok: false; value?: undefined; error: B }; + export const loginFunc = async ( action: "register" | "login" | "refresh", body: object | string, -) => { +): Promise> => { try { const token = await queryFn( { path: ["auth", action, typeof body === "string" && `?token=${body}`], - method: "POST", + method: typeof body === "string" ? "GET" : "POST", body: typeof body === "object" ? body : undefined, authenticated: false, }, TokenP, ); - await setSecureItem("auth", JSON.stringify(token)); - return null; + if (typeof window !== "undefined") + await setSecureItem("auth", JSON.stringify(token)); + return { ok: true, value: token }; } catch (e) { console.error(action, e); - return (e as KyooErrors).errors[0]; + return { ok: false, error: (e as KyooErrors).errors[0] }; } }; -export const getToken = async (cookies?: string): Promise => { +export const getTokenWJ = async (cookies?: string): Promise<[string, Token] | [null, null]> => { // @ts-ignore Web only. const tokenStr = await getSecureItem("auth", cookies); - if (!tokenStr) return null; - const token = JSON.parse(tokenStr) as Token; + if (!tokenStr) return [null, null]; + let token = TokenP.parse(JSON.parse(tokenStr)); - if (token.expire_at > new Date(new Date().getTime() + 10 * 1000)) { - await loginFunc("refresh", token.refresh_token); - return await getToken(); + if (token.expire_at <= new Date(new Date().getTime() + 10 * 1000)) { + const { ok, value: nToken, error } = await loginFunc("refresh", token.refresh_token); + console.log("refreshed", nToken); + if (!ok) console.error("Error refreshing token durring ssr:", error); + else token = nToken; } - return `${token.token_type} ${token.access_token}`; + return [`${token.token_type} ${token.access_token}`, token]; }; + +export const getToken = async (cookies?: string): Promise => + (await getTokenWJ(cookies))[0] + diff --git a/front/packages/models/src/query.tsx b/front/packages/models/src/query.tsx index 9fb770d9..e00cfc21 100644 --- a/front/packages/models/src/query.tsx +++ b/front/packages/models/src/query.tsx @@ -37,49 +37,47 @@ export const kyooUrl = Platform.OS !== "web" ? process.env.PUBLIC_BACK_URL : typeof window === "undefined" - ? process.env.KYOO_URL ?? "http://localhost:5000" - : "/api"; + ? process.env.KYOO_URL ?? "http://localhost:5000" + : "/api"; export const queryFn = async ( context: | QueryFunctionContext | { - path: (string | false | undefined | null)[]; - body?: object; - method: "GET" | "POST"; - authenticated?: boolean; - }, + path: (string | false | undefined | null)[]; + body?: object; + method: "GET" | "POST"; + authenticated?: boolean; + }, type?: z.ZodType, token?: string | null, ): Promise => { if (!kyooUrl) console.error("Kyoo's url is not defined."); // @ts-ignore - if (!token && context.auhtenticated !== false) token = await getToken(); + if (!token && context.authenticated !== false) token = await getToken(); + const path = [kyooUrl] + .concat( + "path" in context + ? context.path.filter((x) => x) + : context.pageParam + ? [context.pageParam] + : (context.queryKey.filter((x) => x) as string[]), + ) + .join("/") + .replace("/?", "?"); let resp; try { - resp = await fetch( - [kyooUrl] - .concat( - "path" in context - ? context.path.filter((x) => x) - : context.pageParam - ? [context.pageParam] - : (context.queryKey.filter((x) => x) as string[]), - ) - .join("/") - .replace("/?", "?"), - { - // @ts-ignore - method: context.method, - // @ts-ignore - body: context.body ? JSON.stringify(context.body) : undefined, - headers: { - ...(token ? { Authorization: token } : {}), - ...("body" in context ? { "Content-Type": "application/json" } : {}), - }, + resp = await fetch(path, { + // @ts-ignore + method: context.method, + // @ts-ignore + body: context.body ? JSON.stringify(context.body) : undefined, + headers: { + ...(token ? { Authorization: token } : {}), + ...("body" in context ? { "Content-Type": "application/json" } : {}), }, - ); + }); } catch (e) { console.log("Fetch error", e); throw { errors: ["Could not reach Kyoo's server."] } as KyooErrors; @@ -95,7 +93,7 @@ export const queryFn = async ( } catch (e) { data = { errors: [error] } as KyooErrors; } - console.log("Invalid response:", data); + console.log(`Invalid response (${path}):`, data); throw data as KyooErrors; } @@ -141,8 +139,8 @@ export type QueryIdentifier = { export type QueryPage = ComponentType & { getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier[]; getLayout?: - | ComponentType<{ page: ReactElement }> - | { Layout: ComponentType<{ page: ReactElement }>; props: object }; + | ComponentType<{ page: ReactElement }> + | { Layout: ComponentType<{ page: ReactElement }>; props: object }; }; const toQueryKey = (query: QueryIdentifier) => { @@ -150,10 +148,10 @@ const toQueryKey = (query: QueryIdentifier) => { return [ ...query.path, "?" + - Object.entries(query.params) - .filter(([_, v]) => v !== undefined) - .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) - .join("&"), + Object.entries(query.params) + .filter(([_, v]) => v !== undefined) + .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) + .join("&"), ]; } else { return query.path; @@ -190,11 +188,10 @@ export const useInfiniteFetch = ( return { ...ret, items: ret.data?.pages.flatMap((x) => x.items) }; }; -export const fetchQuery = async (queries: QueryIdentifier[], cookies?: string) => { +export const fetchQuery = async (queries: QueryIdentifier[], authToken?: string | null) => { // we can't put this check in a function because we want build time optimizations // see https://github.com/vercel/next.js/issues/5354 for details if (typeof window !== "undefined") return {}; - const authToken = await getToken(cookies); const client = createQueryClient(); await Promise.all( diff --git a/front/packages/models/src/secure-store.web.ts b/front/packages/models/src/secure-store.web.ts index 44eee273..3a45da84 100644 --- a/front/packages/models/src/secure-store.web.ts +++ b/front/packages/models/src/secure-store.web.ts @@ -18,16 +18,21 @@ * along with Kyoo. If not, see . */ -export const setSecureItem = async (key: string, value: string): Promise => { +export const setSecureItemSync = (key: string, value?: string) => { const d = new Date(); // A year d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000); - const expires = "expires=" + d.toUTCString(); + const expires = value ? "expires=" + d.toUTCString() : "expires=Thu, 01 Jan 1970 00:00:01 GMT"; document.cookie = key + "=" + value + ";" + expires + ";path=/"; return null; }; +export const setSecureItem = async (key: string, value: string): Promise => + setSecureItemSync(key, value); + export const getSecureItem = async (key: string, cookies?: string): Promise => { + // Don't try to use document's cookies on SSR. + if (!cookies && typeof window === "undefined") return null; const name = key + "="; const decodedCookie = decodeURIComponent(cookies ?? document.cookie); const ca = decodedCookie.split(";"); diff --git a/front/packages/models/src/utils.ts b/front/packages/models/src/utils.ts index 0954563a..508d92f4 100644 --- a/front/packages/models/src/utils.ts +++ b/front/packages/models/src/utils.ts @@ -21,17 +21,7 @@ import { Movie, Show } from "./resources"; import { z } from "zod"; -export const zdate = () => { - return z.preprocess((arg) => { - if (arg instanceof Date) return arg; - - if (typeof arg === "string" && /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?/.test(arg)) { - return new Date(arg); - } - - return undefined; - }, z.date()); -}; +export const zdate = z.coerce.date; export const getDisplayDate = (data: Show | Movie) => { const { diff --git a/front/packages/ui/src/login/login.tsx b/front/packages/ui/src/login/login.tsx index 57ee7574..49f2b04f 100644 --- a/front/packages/ui/src/login/login.tsx +++ b/front/packages/ui/src/login/login.tsx @@ -24,7 +24,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Platform } from "react-native"; import { Trans } from "react-i18next"; -import { useRouter } from 'solito/router' +import { useRouter } from "solito/router"; import { percent, px, useYoshiki } from "yoshiki/native"; import { DefaultLayout } from "../layout"; import { FormPage } from "./form"; @@ -33,7 +33,7 @@ import { PasswordInput } from "./password-input"; export const LoginPage: QueryPage = () => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(null); + const [error, setError] = useState(undefined); const router = useRouter(); const { t } = useTranslation(); @@ -64,7 +64,7 @@ export const LoginPage: QueryPage = () => {