mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-01-27 09:42:30 +00:00
feat: adapt frontend to oidc flow
This commit is contained in:
@@ -159,6 +159,10 @@ code {
|
|||||||
@apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all;
|
@apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
@apply bg-accent border border-border rounded-md p-2;
|
||||||
|
}
|
||||||
|
|
||||||
.lead {
|
.lead {
|
||||||
@apply text-xl text-muted-foreground;
|
@apply text-xl text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
53
frontend/src/lib/hooks/oidc.ts
Normal file
53
frontend/src/lib/hooks/oidc.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export type OIDCValues = {
|
||||||
|
scope: string;
|
||||||
|
response_type: string;
|
||||||
|
client_id: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IuseOIDCParams {
|
||||||
|
values: OIDCValues;
|
||||||
|
compiled: string;
|
||||||
|
isOidc: boolean;
|
||||||
|
missingParams: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalParams: string[] = ["state"];
|
||||||
|
|
||||||
|
export function useOIDCParams(params: URLSearchParams): IuseOIDCParams {
|
||||||
|
let compiled: string = "";
|
||||||
|
let isOidc = false;
|
||||||
|
const missingParams: string[] = [];
|
||||||
|
|
||||||
|
const values: OIDCValues = {
|
||||||
|
scope: params.get("scope") ?? "",
|
||||||
|
response_type: params.get("response_type") ?? "",
|
||||||
|
client_id: params.get("client_id") ?? "",
|
||||||
|
redirect_uri: params.get("redirect_uri") ?? "",
|
||||||
|
state: params.get("state") ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of Object.keys(values)) {
|
||||||
|
if (!values[key as keyof OIDCValues]) {
|
||||||
|
if (!optionalParams.includes(key)) {
|
||||||
|
missingParams.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingParams.length === 0) {
|
||||||
|
isOidc = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOidc) {
|
||||||
|
compiled = new URLSearchParams(values).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
compiled,
|
||||||
|
isOidc,
|
||||||
|
missingParams,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,16 +13,7 @@ import { getOidcClientInfoScehma } from "@/schemas/oidc-schemas";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||||
type AuthorizePageProps = {
|
|
||||||
scope: string;
|
|
||||||
responseType: string;
|
|
||||||
clientId: string;
|
|
||||||
redirectUri: string;
|
|
||||||
state: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionalAuthorizeProps = ["state"];
|
|
||||||
|
|
||||||
export const AuthorizePage = () => {
|
export const AuthorizePage = () => {
|
||||||
const { isLoggedIn } = useUserContext();
|
const { isLoggedIn } = useUserContext();
|
||||||
@@ -30,20 +21,16 @@ export const AuthorizePage = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const {
|
||||||
// If there is a better way to do this, please do let me know
|
values: props,
|
||||||
const props: AuthorizePageProps = {
|
missingParams,
|
||||||
scope: searchParams.get("scope") || "",
|
compiled: compiledOIDCParams,
|
||||||
responseType: searchParams.get("response_type") || "",
|
} = useOIDCParams(searchParams);
|
||||||
clientId: searchParams.get("client_id") || "",
|
|
||||||
redirectUri: searchParams.get("redirect_uri") || "",
|
|
||||||
state: searchParams.get("state") || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const getClientInfo = useQuery({
|
const getClientInfo = useQuery({
|
||||||
queryKey: ["client", props.clientId],
|
queryKey: ["client", props.client_id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/api/oidc/clients/${props.clientId}`);
|
const res = await fetch(`/api/oidc/clients/${props.client_id}`);
|
||||||
const data = await getOidcClientInfoScehma.parseAsync(await res.json());
|
const data = await getOidcClientInfoScehma.parseAsync(await res.json());
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -53,13 +40,13 @@ export const AuthorizePage = () => {
|
|||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
return axios.post("/api/oidc/authorize", {
|
return axios.post("/api/oidc/authorize", {
|
||||||
scope: props.scope,
|
scope: props.scope,
|
||||||
response_type: props.responseType,
|
response_type: props.response_type,
|
||||||
client_id: props.clientId,
|
client_id: props.client_id,
|
||||||
redirect_uri: props.redirectUri,
|
redirect_uri: props.redirect_uri,
|
||||||
state: props.state,
|
state: props.state,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
mutationKey: ["authorize", props.clientId],
|
mutationKey: ["authorize", props.client_id],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.info("Authorized", {
|
toast.info("Authorized", {
|
||||||
description: "You will be soon redirected to your application",
|
description: "You will be soon redirected to your application",
|
||||||
@@ -74,19 +61,17 @@ export const AuthorizePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
// TODO: Pass the params to the login page, so user can login -> authorize
|
return <Navigate to={`/login?${compiledOIDCParams}`} replace />;
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.keys(props).forEach((key) => {
|
if (missingParams.length > 0) {
|
||||||
if (
|
return (
|
||||||
!props[key as keyof AuthorizePageProps] &&
|
<Navigate
|
||||||
!optionalAuthorizeProps.includes(key)
|
to={`/error?error=${encodeURIComponent(`Missing parameters: ${missingParams.join(", ")}`)}`}
|
||||||
) {
|
replace
|
||||||
// TODO: Add reason for error
|
/>
|
||||||
return <Navigate to="/error" replace />;
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (getClientInfo.isLoading) {
|
if (getClientInfo.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -102,8 +87,12 @@ export const AuthorizePage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (getClientInfo.isError) {
|
if (getClientInfo.isError) {
|
||||||
// TODO: Add reason for error
|
return (
|
||||||
return <Navigate to="/error" replace />;
|
<Navigate
|
||||||
|
to={`/error?error=${encodeURIComponent(`Failed to load client information`)}`}
|
||||||
|
replace
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const ContinuePage = () => {
|
|||||||
clearTimeout(auto);
|
clearTimeout(auto);
|
||||||
clearTimeout(reveal);
|
clearTimeout(reveal);
|
||||||
};
|
};
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,15 +5,30 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
|
||||||
export const ErrorPage = () => {
|
export const ErrorPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { search } = useLocation();
|
||||||
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const error = searchParams.get("error") ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="min-w-xs sm:min-w-sm">
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-3xl">{t("errorTitle")}</CardTitle>
|
<CardTitle className="text-3xl">{t("errorTitle")}</CardTitle>
|
||||||
<CardDescription>{t("errorSubtitle")}</CardDescription>
|
<CardDescription className="flex flex-col gap-1.5">
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p>The following error occured while processing your request:</p>
|
||||||
|
<pre>{error}</pre>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>{t("errorSubtitle")}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { OAuthButton } from "@/components/ui/oauth-button";
|
|||||||
import { SeperatorWithChildren } from "@/components/ui/separator";
|
import { SeperatorWithChildren } from "@/components/ui/separator";
|
||||||
import { useAppContext } from "@/context/app-context";
|
import { useAppContext } from "@/context/app-context";
|
||||||
import { useUserContext } from "@/context/user-context";
|
import { useUserContext } from "@/context/user-context";
|
||||||
|
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||||
import { LoginSchema } from "@/schemas/login-schema";
|
import { LoginSchema } from "@/schemas/login-schema";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import axios, { AxiosError } from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
@@ -47,7 +48,11 @@ export const LoginPage = () => {
|
|||||||
const redirectButtonTimer = useRef<number | null>(null);
|
const redirectButtonTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = new URLSearchParams(search);
|
||||||
const redirectUri = searchParams.get("redirect_uri");
|
const {
|
||||||
|
values: props,
|
||||||
|
isOidc,
|
||||||
|
compiled: compiledOIDCParams,
|
||||||
|
} = useOIDCParams(searchParams);
|
||||||
|
|
||||||
const oauthProviders = providers.filter(
|
const oauthProviders = providers.filter(
|
||||||
(provider) => provider.id !== "local" && provider.id !== "ldap",
|
(provider) => provider.id !== "local" && provider.id !== "ldap",
|
||||||
@@ -60,7 +65,7 @@ export const LoginPage = () => {
|
|||||||
const oauthMutation = useMutation({
|
const oauthMutation = useMutation({
|
||||||
mutationFn: (provider: string) =>
|
mutationFn: (provider: string) =>
|
||||||
axios.get(
|
axios.get(
|
||||||
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
|
||||||
),
|
),
|
||||||
mutationKey: ["oauth"],
|
mutationKey: ["oauth"],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -85,9 +90,7 @@ export const LoginPage = () => {
|
|||||||
mutationKey: ["login"],
|
mutationKey: ["login"],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.data.totpPending) {
|
if (data.data.totpPending) {
|
||||||
window.location.replace(
|
window.location.replace(`/totp?${compiledOIDCParams}`);
|
||||||
`/totp?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +99,12 @@ export const LoginPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
redirectTimer.current = window.setTimeout(() => {
|
redirectTimer.current = window.setTimeout(() => {
|
||||||
|
if (isOidc) {
|
||||||
|
window.location.replace(`/authorize?${compiledOIDCParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.location.replace(
|
window.location.replace(
|
||||||
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
|
||||||
);
|
);
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
@@ -115,7 +122,7 @@ export const LoginPage = () => {
|
|||||||
if (
|
if (
|
||||||
providers.find((provider) => provider.id === oauthAutoRedirect) &&
|
providers.find((provider) => provider.id === oauthAutoRedirect) &&
|
||||||
!isLoggedIn &&
|
!isLoggedIn &&
|
||||||
redirectUri
|
props.redirect_uri !== ""
|
||||||
) {
|
) {
|
||||||
// Not sure of a better way to do this
|
// Not sure of a better way to do this
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
@@ -125,7 +132,13 @@ export const LoginPage = () => {
|
|||||||
setShowRedirectButton(true);
|
setShowRedirectButton(true);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [
|
||||||
|
providers,
|
||||||
|
isLoggedIn,
|
||||||
|
props.redirect_uri,
|
||||||
|
oauthAutoRedirect,
|
||||||
|
oauthMutation,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -136,10 +149,10 @@ export const LoginPage = () => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoggedIn && redirectUri) {
|
if (isLoggedIn && props.redirect_uri !== "") {
|
||||||
return (
|
return (
|
||||||
<Navigate
|
<Navigate
|
||||||
to={`/continue?redirect_uri=${encodeURIComponent(redirectUri)}`}
|
to={`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`}
|
||||||
replace
|
replace
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const LogoutPage = () => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle>
|
<CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{provider !== "username" ? (
|
{provider !== "local" && provider !== "ldap" ? (
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="logoutOauthSubtitle"
|
i18nKey="logoutOauthSubtitle"
|
||||||
t={t}
|
t={t}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useEffect, useId, useRef } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Navigate, useLocation } from "react-router";
|
import { Navigate, useLocation } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||||
|
|
||||||
export const TotpPage = () => {
|
export const TotpPage = () => {
|
||||||
const { totpPending } = useUserContext();
|
const { totpPending } = useUserContext();
|
||||||
@@ -26,7 +27,11 @@ export const TotpPage = () => {
|
|||||||
const redirectTimer = useRef<number | null>(null);
|
const redirectTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = new URLSearchParams(search);
|
||||||
const redirectUri = searchParams.get("redirect_uri");
|
const {
|
||||||
|
values: props,
|
||||||
|
isOidc,
|
||||||
|
compiled: compiledOIDCParams,
|
||||||
|
} = useOIDCParams(searchParams);
|
||||||
|
|
||||||
const totpMutation = useMutation({
|
const totpMutation = useMutation({
|
||||||
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
|
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
|
||||||
@@ -37,9 +42,14 @@ export const TotpPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
redirectTimer.current = window.setTimeout(() => {
|
redirectTimer.current = window.setTimeout(() => {
|
||||||
|
if (isOidc) {
|
||||||
|
window.location.replace(`/authorize?${compiledOIDCParams}`);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
window.location.replace(
|
window.location.replace(
|
||||||
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user