mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Handle token refresh on SSR
This commit is contained in:
parent
fdc6a88317
commit
e3be74d519
@ -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<string> permissions = _options.CurrentValue.Default ?? Array.Empty<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -197,7 +197,7 @@ namespace Kyoo.Authentication.Views
|
||||
public async Task<ActionResult<User>> 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<ActionResult<User>> 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<ActionResult<User>> 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<ActionResult<User>> 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);
|
||||
|
@ -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<any>(pageProps ?? { json: {} });
|
||||
const { queryState, token, ...props } = superjson.deserialize<any>(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 (
|
||||
<YoshikiDebug>
|
||||
<>
|
||||
@ -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) };
|
||||
};
|
||||
|
@ -33,38 +33,49 @@ const TokenP = z.object({
|
||||
});
|
||||
type Token = z.infer<typeof TokenP>;
|
||||
|
||||
type Result<A, B> =
|
||||
| { ok: true; value: A; error?: undefined }
|
||||
| { ok: false; value?: undefined; error: B };
|
||||
|
||||
export const loginFunc = async (
|
||||
action: "register" | "login" | "refresh",
|
||||
body: object | string,
|
||||
) => {
|
||||
): Promise<Result<Token, string>> => {
|
||||
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<string | null> => {
|
||||
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<string | null> =>
|
||||
(await getTokenWJ(cookies))[0]
|
||||
|
||||
|
@ -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 <Data,>(
|
||||
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<Data>,
|
||||
token?: string | null,
|
||||
): Promise<Data> => {
|
||||
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 <Data,>(
|
||||
} 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<T = unknown> = {
|
||||
export type QueryPage<Props = {}> = ComponentType<Props> & {
|
||||
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 = <Data,>(query: QueryIdentifier<Data>) => {
|
||||
@ -150,10 +148,10 @@ const toQueryKey = <Data,>(query: QueryIdentifier<Data>) => {
|
||||
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 = <Data,>(
|
||||
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(
|
||||
|
@ -18,16 +18,21 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const setSecureItem = async (key: string, value: string): Promise<null> => {
|
||||
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<null> =>
|
||||
setSecureItemSync(key, value);
|
||||
|
||||
export const getSecureItem = async (key: string, cookies?: string): Promise<string | null> => {
|
||||
// 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(";");
|
||||
|
@ -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 {
|
||||
|
@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
@ -64,7 +64,7 @@ export const LoginPage: QueryPage = () => {
|
||||
<Button
|
||||
text={t("login.login")}
|
||||
onPress={async () => {
|
||||
const error = await loginFunc("login", {username, password});
|
||||
const { error } = await loginFunc("login", { username, password });
|
||||
setError(error);
|
||||
if (!error) router.push("/");
|
||||
}}
|
||||
|
@ -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";
|
||||
@ -35,7 +35,7 @@ export const RegisterPage: QueryPage = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
@ -79,7 +79,7 @@ export const RegisterPage: QueryPage = () => {
|
||||
text={t("login.register")}
|
||||
disabled={password !== confirm}
|
||||
onPress={async () => {
|
||||
const error = await loginFunc("register", { email, username, password });
|
||||
const { error } = await loginFunc("register", { email, username, password });
|
||||
setError(error);
|
||||
if (!error) router.push("/");
|
||||
}}
|
||||
|
Loading…
x
Reference in New Issue
Block a user