mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-02-28 03:42:00 +00:00
feat: oidc (#605)
* chore: add oidc base config * wip: authorize page * feat: implement basic oidc functionality * refactor: implement oidc following tinyauth patterns * feat: adapt frontend to oidc flow * fix: review comments * fix: oidc review comments * feat: refresh token grant type support * feat: cleanup expired oidc sessions * feat: frontend i18n * fix: fix typo in error screen * tests: add basic testing * fix: more review comments * refactor: rework oidc error messages * feat: openid discovery endpoint * feat: jwk endpoint * i18n: fix typo * fix: more rabbit nitpicks * fix: final review comments * i18n: authorize page error messages
This commit is contained in:
199
frontend/src/pages/authorize-page.tsx
Normal file
199
frontend/src/pages/authorize-page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useUserContext } from "@/context/user-context";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Navigate, useNavigate } from "react-router";
|
||||
import { useLocation } from "react-router";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { getOidcClientInfoSchema } from "@/schemas/oidc-schemas";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import { Mail, Shield, User, Users } from "lucide-react";
|
||||
|
||||
type Scope = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
const scopeMapIconProps = {
|
||||
className: "stroke-card stroke-2.5",
|
||||
};
|
||||
|
||||
const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
|
||||
return [
|
||||
{
|
||||
id: "openid",
|
||||
name: t("openidScopeName"),
|
||||
description: t("openidScopeDescription"),
|
||||
icon: <Shield {...scopeMapIconProps} />,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
name: t("emailScopeName"),
|
||||
description: t("emailScopeDescription"),
|
||||
icon: <Mail {...scopeMapIconProps} />,
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
name: t("profileScopeName"),
|
||||
description: t("profileScopeDescription"),
|
||||
icon: <User {...scopeMapIconProps} />,
|
||||
},
|
||||
{
|
||||
id: "groups",
|
||||
name: t("groupsScopeName"),
|
||||
description: t("groupsScopeDescription"),
|
||||
icon: <Users {...scopeMapIconProps} />,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const AuthorizePage = () => {
|
||||
const { isLoggedIn } = useUserContext();
|
||||
const { search } = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const scopeMap = createScopeMap(t);
|
||||
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const {
|
||||
values: props,
|
||||
missingParams,
|
||||
isOidc,
|
||||
compiled: compiledOIDCParams,
|
||||
} = useOIDCParams(searchParams);
|
||||
const scopes = props.scope ? props.scope.split(" ").filter(Boolean) : [];
|
||||
|
||||
const getClientInfo = useQuery({
|
||||
queryKey: ["client", props.client_id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/oidc/clients/${props.client_id}`);
|
||||
const data = await getOidcClientInfoSchema.parseAsync(await res.json());
|
||||
return data;
|
||||
},
|
||||
enabled: isOidc,
|
||||
});
|
||||
|
||||
const authorizeMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
return axios.post("/api/oidc/authorize", {
|
||||
scope: props.scope,
|
||||
response_type: props.response_type,
|
||||
client_id: props.client_id,
|
||||
redirect_uri: props.redirect_uri,
|
||||
state: props.state,
|
||||
});
|
||||
},
|
||||
mutationKey: ["authorize", props.client_id],
|
||||
onSuccess: (data) => {
|
||||
toast.info(t("authorizeSuccessTitle"), {
|
||||
description: t("authorizeSuccessSubtitle"),
|
||||
});
|
||||
window.location.replace(data.data.redirect_uri);
|
||||
},
|
||||
onError: (error) => {
|
||||
window.location.replace(
|
||||
`/error?error=${encodeURIComponent(error.message)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (missingParams.length > 0) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/error?error=${encodeURIComponent(t("authorizeErrorMissingParams", { missingParams: missingParams.join(", ") }))}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to={`/login?${compiledOIDCParams}`} replace />;
|
||||
}
|
||||
|
||||
if (getClientInfo.isLoading) {
|
||||
return (
|
||||
<Card className="min-w-xs sm:min-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">
|
||||
{t("authorizeLoadingTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("authorizeLoadingSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (getClientInfo.isError) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/error?error=${encodeURIComponent(t("authorizeErrorClientInfo"))}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="min-w-xs sm:min-w-sm mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">
|
||||
{t("authorizeCardTitle", {
|
||||
app: getClientInfo.data?.name || "Unknown",
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{scopes.includes("openid")
|
||||
? t("authorizeSubtitle")
|
||||
: t("authorizeSubtitleOAuth")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{scopes.includes("openid") && (
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{scopes.map((id) => {
|
||||
const scope = scopeMap.find((s) => s.id === id);
|
||||
if (!scope) return null;
|
||||
return (
|
||||
<div key={scope.id} className="flex flex-row items-center gap-3">
|
||||
<div className="p-2 flex flex-col items-center justify-center bg-card-foreground rounded-md">
|
||||
{scope.icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="text-md">{scope.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{scope.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
)}
|
||||
<CardFooter className="flex flex-col items-stretch gap-2">
|
||||
<Button
|
||||
onClick={() => authorizeMutation.mutate()}
|
||||
loading={authorizeMutation.isPending}
|
||||
>
|
||||
{t("authorizeTitle")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/")}
|
||||
disabled={authorizeMutation.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancelTitle")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -80,7 +80,7 @@ export const ContinuePage = () => {
|
||||
clearTimeout(auto);
|
||||
clearTimeout(reveal);
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
|
||||
@@ -5,15 +5,30 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
export const ErrorPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const error = searchParams.get("error") ?? "";
|
||||
|
||||
return (
|
||||
<Card className="min-w-xs sm:min-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">{t("errorTitle")}</CardTitle>
|
||||
<CardDescription>{t("errorSubtitle")}</CardDescription>
|
||||
<CardDescription className="flex flex-col gap-1.5">
|
||||
{error ? (
|
||||
<>
|
||||
<p>{t("errorSubtitleInfo")}</p>
|
||||
<pre>{error}</pre>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{t("errorSubtitle")}</p>
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { OAuthButton } from "@/components/ui/oauth-button";
|
||||
import { SeperatorWithChildren } from "@/components/ui/separator";
|
||||
import { useAppContext } from "@/context/app-context";
|
||||
import { useUserContext } from "@/context/user-context";
|
||||
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||
import { LoginSchema } from "@/schemas/login-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios, { AxiosError } from "axios";
|
||||
@@ -47,7 +48,11 @@ export const LoginPage = () => {
|
||||
const redirectButtonTimer = useRef<number | null>(null);
|
||||
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const redirectUri = searchParams.get("redirect_uri");
|
||||
const {
|
||||
values: props,
|
||||
isOidc,
|
||||
compiled: compiledOIDCParams,
|
||||
} = useOIDCParams(searchParams);
|
||||
|
||||
const oauthProviders = providers.filter(
|
||||
(provider) => provider.id !== "local" && provider.id !== "ldap",
|
||||
@@ -60,7 +65,7 @@ export const LoginPage = () => {
|
||||
const oauthMutation = useMutation({
|
||||
mutationFn: (provider: string) =>
|
||||
axios.get(
|
||||
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
||||
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
|
||||
),
|
||||
mutationKey: ["oauth"],
|
||||
onSuccess: (data) => {
|
||||
@@ -86,7 +91,7 @@ export const LoginPage = () => {
|
||||
onSuccess: (data) => {
|
||||
if (data.data.totpPending) {
|
||||
window.location.replace(
|
||||
`/totp?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
||||
`/totp?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -96,8 +101,12 @@ export const LoginPage = () => {
|
||||
});
|
||||
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
if (isOidc) {
|
||||
window.location.replace(`/authorize?${compiledOIDCParams}`);
|
||||
return;
|
||||
}
|
||||
window.location.replace(
|
||||
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
||||
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
|
||||
);
|
||||
}, 500);
|
||||
},
|
||||
@@ -115,7 +124,7 @@ export const LoginPage = () => {
|
||||
if (
|
||||
providers.find((provider) => provider.id === oauthAutoRedirect) &&
|
||||
!isLoggedIn &&
|
||||
redirectUri
|
||||
props.redirect_uri !== ""
|
||||
) {
|
||||
// Not sure of a better way to do this
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
@@ -125,7 +134,13 @@ export const LoginPage = () => {
|
||||
setShowRedirectButton(true);
|
||||
}, 5000);
|
||||
}
|
||||
}, []);
|
||||
}, [
|
||||
providers,
|
||||
isLoggedIn,
|
||||
props.redirect_uri,
|
||||
oauthAutoRedirect,
|
||||
oauthMutation,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -136,10 +151,14 @@ export const LoginPage = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
if (isLoggedIn && redirectUri) {
|
||||
if (isLoggedIn && isOidc) {
|
||||
return <Navigate to={`/authorize?${compiledOIDCParams}`} replace />;
|
||||
}
|
||||
|
||||
if (isLoggedIn && props.redirect_uri !== "") {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/continue?redirect_uri=${encodeURIComponent(redirectUri)}`}
|
||||
to={`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const LogoutPage = () => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{provider !== "username" ? (
|
||||
{provider !== "local" && provider !== "ldap" ? (
|
||||
<Trans
|
||||
i18nKey="logoutOauthSubtitle"
|
||||
t={t}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEffect, useId, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Navigate, useLocation } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||
|
||||
export const TotpPage = () => {
|
||||
const { totpPending } = useUserContext();
|
||||
@@ -26,7 +27,11 @@ export const TotpPage = () => {
|
||||
const redirectTimer = useRef<number | null>(null);
|
||||
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const redirectUri = searchParams.get("redirect_uri");
|
||||
const {
|
||||
values: props,
|
||||
isOidc,
|
||||
compiled: compiledOIDCParams,
|
||||
} = useOIDCParams(searchParams);
|
||||
|
||||
const totpMutation = useMutation({
|
||||
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
|
||||
@@ -37,9 +42,14 @@ export const TotpPage = () => {
|
||||
});
|
||||
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
window.location.replace(
|
||||
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
||||
);
|
||||
if (isOidc) {
|
||||
window.location.replace(`/authorize?${compiledOIDCParams}`);
|
||||
return;
|
||||
} else {
|
||||
window.location.replace(
|
||||
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
Reference in New Issue
Block a user