Compare commits

..

4 Commits

Author SHA1 Message Date
Ryc O'Chet 42ce47dab3 Ensure the ldap service uses the correct value 2026-06-09 10:53:44 +01:00
Ryc O'Chet b68426d931 Move secret loading to only run once 2026-06-09 10:28:23 +01:00
Ryc O'Chet b90f95a17d Fix missing import 2026-06-08 12:55:01 +01:00
Ryc O'Chet 3d28c6e6d9 Add LDAP BindPasswordFile
Fixes #927
2026-06-08 12:46:39 +01:00
34 changed files with 1297 additions and 1593 deletions
@@ -0,0 +1,36 @@
import { languages, SupportedLanguage } from "@/lib/i18n/locales";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { useState } from "react";
import i18n from "@/lib/i18n/i18n";
export const LanguageSelector = () => {
const [language, setLanguage] = useState<SupportedLanguage>(
i18n.language as SupportedLanguage,
);
const handleSelect = (option: string) => {
setLanguage(option as SupportedLanguage);
i18n.changeLanguage(option as SupportedLanguage);
};
return (
<Select onValueChange={handleSelect} value={language}>
<SelectTrigger aria-label="Select language">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{Object.entries(languages).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
+5 -3
View File
@@ -1,8 +1,9 @@
import { useAppContext } from "@/context/app-context"; import { useAppContext } from "@/context/app-context";
import { LanguageSelector } from "../language/language";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DomainWarning } from "../domain-warning/domain-warning"; import { DomainWarning } from "../domain-warning/domain-warning";
import { QuickActions } from "../quick-actions/quick-actions"; import { ThemeToggle } from "../theme-toggle/theme-toggle";
const BaseLayout = ({ children }: { children: React.ReactNode }) => { const BaseLayout = ({ children }: { children: React.ReactNode }) => {
const { ui } = useAppContext(); const { ui } = useAppContext();
@@ -20,8 +21,9 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
backgroundPosition: "center", backgroundPosition: "center",
}} }}
> >
<div className="absolute top-4 right-4"> <div className="absolute top-4 right-4 flex flex-row gap-2">
<QuickActions /> <ThemeToggle />
<LanguageSelector />
</div> </div>
<div className="max-w-sm md:min-w-sm min-w-xs">{children}</div> <div className="max-w-sm md:min-w-sm min-w-xs">{children}</div>
</div> </div>
@@ -1,208 +0,0 @@
import { languages, SupportedLanguage } from "@/lib/i18n/locales";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { useState } from "react";
import i18n from "@/lib/i18n/i18n";
import { useUserContext } from "@/context/user-context";
import { ScrollArea } from "../ui/scroll-area";
import { useTheme } from "../providers/theme-provider";
import {
Check,
DoorOpenIcon,
Languages,
Monitor,
Moon,
Palette,
Settings,
Sun,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import { useRef } from "react";
import {
useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { toast } from "sonner";
import { useEffect } from "react";
function Avatar({ initial }: { initial: string }) {
return (
<span className="group relative grid size-10 place-items-center rounded-full">
<span className="absolute inset-0 overflow-hidden rounded-full bg-linear-to-b from-neutral-50 to-neutral-100 dark:from-neutral-700 dark:to-neutral-950 shadow-lg"></span>
<span className="relative text-sm font-semibold text-primary">
{initial}
</span>
</span>
);
}
export const QuickActions = () => {
const { auth } = useUserContext();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const { search } = useLocation();
const [language, setLanguage] = useState<SupportedLanguage>(
i18n.language as SupportedLanguage,
);
const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams);
const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/user/logout"),
mutationKey: ["logout"],
onSuccess: () => {
toast.success(t("logoutSuccessTitle"), {
description: t("logoutSuccessSubtitle"),
});
redirectTimer.current = window.setTimeout(() => {
window.location.replace(`/login${compiledParams}`);
}, 500);
},
onError: () => {
toast.error(t("logoutFailTitle"), {
description: t("logoutFailSubtitle"),
});
},
});
useEffect(() => {
return () => {
if (redirectTimer.current) {
clearTimeout(redirectTimer.current);
}
};
}, [redirectTimer]);
const initial = auth.authenticated
? (auth.name[0] || "U").toUpperCase()
: null;
const handleSelect = (option: string) => {
setLanguage(option as SupportedLanguage);
i18n.changeLanguage(option as SupportedLanguage);
};
const themes = [
{ key: "light", label: t("quickActionsThemeLight"), icon: Sun },
{ key: "dark", label: t("quickActionsThemeDark"), icon: Moon },
{ key: "system", label: t("quickActionsThemeSystem"), icon: Monitor },
] as const;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={t("quickActionsTitle")}
className="rounded-full transition-transform duration-200 will-change-transform hover:scale-105 hover:cursor-pointer focus:ring-0 focus:outline-3 focus:outline-ring/50"
>
{auth.authenticated ? (
<Avatar initial={initial!} />
) : (
<span className="bg-card text-primary border-border size-10 flex items-center justify-center rounded-full border shadow-lg">
<Settings className="size-4" />
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="rounded-xl p-1"
>
{auth.authenticated && (
<>
<DropdownMenuLabel className="flex items-center gap-3 p-2">
<div className="bg-foreground text-background flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-medium">
{initial}
</div>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{auth.name}
</span>
<span className="text-muted-foreground truncate text-xs font-normal">
{auth.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="size-4" />
{t("quickActionsLanguage")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent sideOffset={8} className="rounded-xl p-1">
<ScrollArea className="h-80">
{Object.entries(languages).map(([key, value]) => (
<DropdownMenuItem
key={key}
onSelect={() => handleSelect(key)}
>
{value}
{language === key && <Check className="size-4" />}
</DropdownMenuItem>
))}
</ScrollArea>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="size-4" />
{t("quickActionsTheme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="rounded-xl p-1" sideOffset={8}>
{themes.map(({ key, label, icon: Icon }) => (
<DropdownMenuItem key={key} onClick={() => setTheme(key)}>
<span className="flex items-center gap-2">
<Icon className="size-4" />
{label}
</span>
{theme === key && <Check className="size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{auth.authenticated && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => logoutMutation.mutate()}
className="text-destructive"
>
<DoorOpenIcon className="size-4" />
{t("quickActionsLogout")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,40 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/providers/theme-provider";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="bg-card text-card-foreground hover:bg-card/90"
size="icon"
>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,56 +0,0 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
-17
View File
@@ -1,17 +0,0 @@
type UseLoginForProps = {
login_for?: "oidc" | "app";
compiledParams: string;
};
export const useLoginFor = (props: UseLoginForProps): string => {
const { login_for, compiledParams } = props;
switch (login_for) {
case "oidc":
return "/oidc/authorize" + compiledParams;
case "app":
return "/continue" + compiledParams;
default:
return "/logout";
}
};
+76
View File
@@ -0,0 +1,76 @@
import { z } from "zod";
export const oidcParamsSchema = z.object({
scope: z.string().min(1),
response_type: z.string().min(1),
client_id: z.string().min(1),
redirect_uri: z.string().min(1),
state: z.string().optional(),
nonce: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
});
function b64urlDecode(s: string): string {
const base64 = s.replace(/-/g, "+").replace(/_/g, "/");
return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="));
}
function decodeRequestObject(jwt: string): Record<string, string> {
try {
// Must have exactly 3 parts: header, payload, signature
const parts = jwt.split(".");
if (parts.length !== 3) return {};
// Header must specify "alg": "none" and signature must be empty string
const header = JSON.parse(b64urlDecode(parts[0]));
if (!header || typeof header !== "object" || header.alg !== "none" || parts[2] !== "") return {};
const payload = JSON.parse(b64urlDecode(parts[1]));
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(payload)) {
if (typeof v === "string") result[k] = v;
}
return result;
} catch {
return {};
}
}
export const useOIDCParams = (
params: URLSearchParams,
): {
values: z.infer<typeof oidcParamsSchema>;
issues: string[];
isOidc: boolean;
compiled: string;
} => {
const obj = Object.fromEntries(params.entries());
// RFC 9101 / OIDC Core 6.1: if `request` param present, decode JWT payload
// and merge claims over top-level params (JWT claims take precedence)
const requestJwt = params.get("request");
if (requestJwt) {
const claims = decodeRequestObject(requestJwt);
Object.assign(obj, claims);
}
const parsed = oidcParamsSchema.safeParse(obj);
if (parsed.success) {
return {
values: parsed.data,
issues: [],
isOidc: true,
compiled: new URLSearchParams(parsed.data).toString(),
};
}
return {
issues: parsed.error.issues.map((issue) => issue.path.toString()),
values: {} as z.infer<typeof oidcParamsSchema>,
isOidc: false,
compiled: "",
};
};
+2 -2
View File
@@ -7,7 +7,7 @@ type IuseRedirectUri = {
}; };
export const useRedirectUri = ( export const useRedirectUri = (
redirect_uri: string | undefined, redirect_uri: string | null,
cookieDomain: string, cookieDomain: string,
): IuseRedirectUri => { ): IuseRedirectUri => {
let isValid = false; let isValid = false;
@@ -15,7 +15,7 @@ export const useRedirectUri = (
let isAllowedProto = false; let isAllowedProto = false;
let isHttpsDowngrade = false; let isHttpsDowngrade = false;
if (redirect_uri === undefined) { if (!redirect_uri) {
return { return {
valid: isValid, valid: isValid,
trusted: isTrusted, trusted: isTrusted,
-40
View File
@@ -1,40 +0,0 @@
import { z } from "zod";
type ScreenParams = {
login_for?: "oidc" | "app";
redirect_uri?: string;
oidc_ticket?: string;
oidc_scope?: string;
oidc_name?: string;
};
const zodScreenParams = z.object({
login_for: z.enum(["oidc", "app"]).optional(),
redirect_uri: z.string().optional(),
oidc_ticket: z.string().optional(),
oidc_scope: z.string().optional(),
oidc_name: z.string().optional(),
});
export function useScreenParams(params: URLSearchParams): ScreenParams {
const paramsObj = Object.fromEntries(params.entries());
const parsed = zodScreenParams.safeParse(paramsObj);
if (!parsed.success) {
return {};
}
return parsed.data;
}
export function recompileScreenParams(params: ScreenParams): string {
const p = new URLSearchParams(
Object.fromEntries(
Object.entries(params).filter(([, v]) => v != null),
) as Record<string, string>,
).toString();
if (p.length > 0) {
return "?" + p;
}
return "";
}
+1 -8
View File
@@ -92,12 +92,5 @@
"loginTailscaleOtherMethod": "Login with another method", "loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.", "loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.", "loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.", "logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
"quickActionsLanguage": "Language",
"quickActionsTheme": "Theme",
"quickActionsThemeLight": "Light",
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsTitle": "Quick Actions"
} }
+1 -8
View File
@@ -92,12 +92,5 @@
"loginTailscaleOtherMethod": "Login with another method", "loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.", "loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.", "loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.", "logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
"quickActionsLanguage": "Language",
"quickActionsTheme": "Theme",
"quickActionsThemeLight": "Light",
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsTitle": "Quick Actions"
} }
+1 -4
View File
@@ -35,10 +35,7 @@ createRoot(document.getElementById("root")!).render(
<Route element={<Layout />} errorElement={<ErrorPage />}> <Route element={<Layout />} errorElement={<ErrorPage />}>
<Route path="/" element={<App />} /> <Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route path="/authorize" element={<AuthorizePage />} />
path="/oidc/authorize"
element={<AuthorizePage />}
/>
<Route path="/logout" element={<LogoutPage />} /> <Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} /> <Route path="/continue" element={<ContinuePage />} />
<Route path="/totp" element={<TotpPage />} /> <Route path="/totp" element={<TotpPage />} />
+50 -24
View File
@@ -1,5 +1,5 @@
import { useUserContext } from "@/context/user-context"; import { useUserContext } from "@/context/user-context";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Navigate, useNavigate } from "react-router"; import { Navigate, useNavigate } from "react-router";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { import {
@@ -10,9 +10,11 @@ import {
CardFooter, CardFooter,
CardContent, CardContent,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { getOidcClientInfoSchema } 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";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react"; import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react";
@@ -21,10 +23,6 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
type Scope = { type Scope = {
id: string; id: string;
@@ -86,17 +84,27 @@ export const AuthorizePage = () => {
const scopeMap = createScopeMap(t); const scopeMap = createScopeMap(t);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const oidcParams = useOIDCParams(searchParams);
const isOidc = screenParams.login_for === "oidc";
const compiledParams = recompileScreenParams(screenParams); const getClientInfo = useQuery({
queryKey: ["client", oidcParams.values.client_id],
queryFn: async () => {
const res = await fetch(
`/api/oidc/clients/${encodeURIComponent(oidcParams.values.client_id)}`,
);
const data = await getOidcClientInfoSchema.parseAsync(await res.json());
return data;
},
enabled: oidcParams.isOidc,
});
const authorizeMutation = useMutation({ const authorizeMutation = useMutation({
mutationFn: () => { mutationFn: () => {
return axios.post("/api/oidc/authorize-complete", { return axios.post("/api/oidc/authorize", {
ticket: screenParams.oidc_ticket, ...oidcParams.values,
}); });
}, },
mutationKey: ["authorize", screenParams.oidc_ticket], mutationKey: ["authorize", oidcParams.values.client_id],
onSuccess: (data) => { onSuccess: (data) => {
toast.info(t("authorizeSuccessTitle"), { toast.info(t("authorizeSuccessTitle"), {
description: t("authorizeSuccessSubtitle"), description: t("authorizeSuccessSubtitle"),
@@ -110,38 +118,56 @@ export const AuthorizePage = () => {
}, },
}); });
if ( if (oidcParams.issues.length > 0) {
!isOidc ||
screenParams.oidc_ticket === undefined ||
screenParams.oidc_scope === undefined
) {
return ( return (
<Navigate <Navigate
to={`/error?error=${encodeURIComponent(t("authorizeErrorInvalidParams"))}`} to={`/error?error=${encodeURIComponent(t("authorizeErrorMissingParams", { missingParams: oidcParams.issues.join(", ") }))}`}
replace replace
/> />
); );
} }
if (!auth.authenticated) { if (!auth.authenticated) {
return <Navigate to={`/login${compiledParams}`} replace />; return <Navigate to={`/login?${oidcParams.compiled}`} replace />;
}
if (getClientInfo.isLoading) {
return (
<Card className="gap-0">
<CardHeader>
<CardTitle className="text-xl">
{t("authorizeLoadingTitle")}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>{t("authorizeLoadingSubtitle")}</CardDescription>
</CardContent>
</Card>
);
}
if (getClientInfo.isError) {
return (
<Navigate
to={`/error?error=${encodeURIComponent(t("authorizeErrorClientInfo"))}`}
replace
/>
);
} }
const scopes = const scopes =
screenParams.oidc_scope.split(" ").filter((s) => s.trim() !== "") || []; oidcParams.values.scope.split(" ").filter((s) => s.trim() !== "") || [];
return ( return (
<Card> <Card>
<CardHeader className="mb-2"> <CardHeader className="mb-2">
<div className="flex flex-col gap-3 items-center justify-center text-center"> <div className="flex flex-col gap-3 items-center justify-center text-center">
<div className="bg-accent-foreground box-content text-muted text-xl font-bold font-sans rounded-lg size-8 p-2 flex items-center justify-center"> <div className="bg-accent-foreground box-content text-muted text-xl font-bold font-sans rounded-lg size-8 p-2 flex items-center justify-center">
{screenParams.oidc_name !== undefined {getClientInfo.data?.name.slice(0, 1) || "U"}
? screenParams.oidc_name.slice(0, 1)
: "U"}
</div> </div>
<CardTitle className="text-xl"> <CardTitle className="text-xl">
{t("authorizeCardTitle", { {t("authorizeCardTitle", {
app: screenParams.oidc_name || "Unknown", app: getClientInfo.data?.name || "Unknown",
})} })}
</CardTitle> </CardTitle>
<CardDescription className="text-sm max-w-sm"> <CardDescription className="text-sm max-w-sm">
@@ -180,7 +206,7 @@ export const AuthorizePage = () => {
{t("authorizeTitle")} {t("authorizeTitle")}
</Button> </Button>
<Button <Button
onClick={() => navigate(`/logout${compiledParams}`)} onClick={() => navigate("/")}
disabled={authorizeMutation.isPending} disabled={authorizeMutation.isPending}
variant="outline" variant="outline"
> >
+9 -12
View File
@@ -12,10 +12,6 @@ import { Trans, useTranslation } from "react-i18next";
import { Navigate, useLocation, useNavigate } from "react-router"; import { Navigate, useLocation, useNavigate } from "react-router";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRedirectUri } from "@/lib/hooks/redirect-uri"; import { useRedirectUri } from "@/lib/hooks/redirect-uri";
import {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
export const ContinuePage = () => { export const ContinuePage = () => {
const { app, ui } = useAppContext(); const { app, ui } = useAppContext();
@@ -29,10 +25,7 @@ export const ContinuePage = () => {
const hasRedirected = useRef(false); const hasRedirected = useRef(false);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const redirectUri = searchParams.get("redirect_uri");
const redirectUri = screenParams.redirect_uri;
const isAppLogin = screenParams.login_for === "app";
const recompiledParams = recompileScreenParams(screenParams);
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri( const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
redirectUri, redirectUri,
@@ -50,8 +43,7 @@ export const ContinuePage = () => {
auth.authenticated && auth.authenticated &&
hasValidRedirect && hasValidRedirect &&
!showUntrustedWarning && !showUntrustedWarning &&
!showInsecureWarning && !showInsecureWarning;
isAppLogin;
const redirectToTarget = useCallback(() => { const redirectToTarget = useCallback(() => {
if (!urlHref || hasRedirected.current) { if (!urlHref || hasRedirected.current) {
@@ -87,10 +79,15 @@ export const ContinuePage = () => {
}, [shouldAutoRedirect, redirectToTarget]); }, [shouldAutoRedirect, redirectToTarget]);
if (!auth.authenticated) { if (!auth.authenticated) {
return <Navigate to={`/login${recompiledParams}`} replace />; return (
<Navigate
to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
replace
/>
);
} }
if (!hasValidRedirect || !isAppLogin) { if (!hasValidRedirect) {
return <Navigate to="/logout" replace />; return <Navigate to="/logout" replace />;
} }
+4 -7
View File
@@ -11,18 +11,12 @@ import { useAppContext } from "@/context/app-context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
export const ForgotPasswordPage = () => { export const ForgotPasswordPage = () => {
const { ui } = useAppContext(); const { ui } = useAppContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation(); const { search } = useLocation();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams);
return ( return (
<Card> <Card>
@@ -43,7 +37,10 @@ export const ForgotPasswordPage = () => {
className="w-full" className="w-full"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
window.location.replace(`/login${compiledParams}`); const eparams = searchParams.toString();
window.location.replace(
`/login${eparams.length > 0 ? `?${eparams}` : ""}`,
);
}} }}
> >
{t("backToLoginButton")} {t("backToLoginButton")}
+52 -23
View File
@@ -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";
@@ -25,11 +26,6 @@ import { useEffect, useId, useRef, useState } 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 {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for";
const iconMap: Record<string, React.ReactNode> = { const iconMap: Record<string, React.ReactNode> = {
google: <GoogleIcon />, google: <GoogleIcon />,
@@ -50,9 +46,7 @@ export const LoginPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showRedirectButton, setShowRedirectButton] = useState(false); const [showRedirectButton, setShowRedirectButton] = useState(false);
const [useTailscale, setUseTailscale] = useState( const [useTailscale, setUseTailscale] = useState(tailscale.nodeName !== undefined);
tailscale.nodeName !== undefined,
);
const hasAutoRedirectedRef = useRef(false); const hasAutoRedirectedRef = useRef(false);
@@ -62,22 +56,17 @@ export const LoginPage = () => {
const formId = useId(); const formId = useId();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const redirectUri = searchParams.get("redirect_uri") || undefined;
const compiledParams = recompileScreenParams(screenParams); const oidcParams = useOIDCParams(searchParams);
const loginForUrl = useLoginFor({
login_for: screenParams.login_for,
compiledParams,
});
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState( const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
providers.find((provider) => provider.id === oauth.autoRedirect) !== providers.find((provider) => provider.id === oauth.autoRedirect) !==
undefined && screenParams.redirect_uri !== undefined, undefined && redirectUri !== undefined,
); );
const oauthProviders = providers.filter( const oauthProviders = providers.filter(
(provider) => provider.id !== "local" && provider.id !== "ldap", (provider) => provider.id !== "local" && provider.id !== "ldap",
); );
const userAuthConfigured = const userAuthConfigured =
providers.find( providers.find(
(provider) => provider.id === "local" || provider.id === "ldap", (provider) => provider.id === "local" || provider.id === "ldap",
@@ -90,7 +79,16 @@ export const LoginPage = () => {
variables: oauthVariables, variables: oauthVariables,
} = useMutation({ } = useMutation({
mutationFn: (provider: string) => { mutationFn: (provider: string) => {
return axios.get(`/api/oauth/url/${provider}${compiledParams}`); const getParams = function (): string {
if (oidcParams.isOidc) {
return `?${oidcParams.compiled}`;
}
if (redirectUri) {
return `?redirect_uri=${encodeURIComponent(redirectUri)}`;
}
return "";
};
return axios.get(`/api/oauth/url/${provider}${getParams()}`);
}, },
mutationKey: ["oauth"], mutationKey: ["oauth"],
onSuccess: (data) => { onSuccess: (data) => {
@@ -121,7 +119,13 @@ export const LoginPage = () => {
mutationKey: ["login"], mutationKey: ["login"],
onSuccess: (data) => { onSuccess: (data) => {
if (data.data.totpPending) { if (data.data.totpPending) {
window.location.replace(`/totp${compiledParams}`); if (oidcParams.isOidc) {
window.location.replace(`/totp?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/totp${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
return; return;
} }
@@ -130,7 +134,13 @@ export const LoginPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.replace(loginForUrl); if (oidcParams.isOidc) {
window.location.replace(`/authorize?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
}, 500); }, 500);
}, },
onError: (error: AxiosError) => { onError: (error: AxiosError) => {
@@ -153,7 +163,13 @@ export const LoginPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.replace(loginForUrl); if (oidcParams.isOidc) {
window.location.replace(`/authorize?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
}, 500); }, 500);
}, },
onError: () => { onError: () => {
@@ -168,7 +184,7 @@ export const LoginPage = () => {
!auth.authenticated && !auth.authenticated &&
isOauthAutoRedirect && isOauthAutoRedirect &&
!hasAutoRedirectedRef.current && !hasAutoRedirectedRef.current &&
screenParams.login_for !== undefined redirectUri !== undefined
) { ) {
hasAutoRedirectedRef.current = true; hasAutoRedirectedRef.current = true;
oauthMutate(oauth.autoRedirect); oauthMutate(oauth.autoRedirect);
@@ -179,7 +195,7 @@ export const LoginPage = () => {
hasAutoRedirectedRef, hasAutoRedirectedRef,
oauth.autoRedirect, oauth.autoRedirect,
isOauthAutoRedirect, isOauthAutoRedirect,
screenParams.login_for, redirectUri,
]); ]);
useEffect(() => { useEffect(() => {
@@ -194,8 +210,21 @@ export const LoginPage = () => {
}; };
}, [redirectTimer, redirectButtonTimer]); }, [redirectTimer, redirectButtonTimer]);
if (auth.authenticated && oidcParams.isOidc) {
return <Navigate to={`/authorize?${oidcParams.compiled}`} replace />;
}
if (auth.authenticated && redirectUri !== undefined) {
return (
<Navigate
to={`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
replace
/>
);
}
if (auth.authenticated) { if (auth.authenticated) {
return <Navigate to={loginForUrl} replace />; return <Navigate to="/logout" replace />;
} }
if (isOauthAutoRedirect) { if (isOauthAutoRedirect) {
+2 -11
View File
@@ -15,21 +15,12 @@ import { Navigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { type UseMutationResult } from "@tanstack/react-query"; import { type UseMutationResult } from "@tanstack/react-query";
import { type AxiosResponse } from "axios"; import { type AxiosResponse } from "axios";
import { useLocation } from "react-router";
import {
useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params";
export const LogoutPage = () => { export const LogoutPage = () => {
const { auth, oauth, tailscale } = useUserContext(); const { auth, oauth, tailscale } = useUserContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation();
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams);
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/user/logout"), mutationFn: () => axios.post("/api/user/logout"),
@@ -40,7 +31,7 @@ export const LogoutPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.replace(`/login${compiledParams}`); window.location.replace("/login");
}, 500); }, 500);
}, },
onError: () => { onError: () => {
@@ -59,7 +50,7 @@ export const LogoutPage = () => {
}, [redirectTimer]); }, [redirectTimer]);
if (!auth.authenticated) { if (!auth.authenticated) {
return <Navigate to={`/login${compiledParams}`} replace />; return <Navigate to="/login" replace />;
} }
if (oauth.active) { if (oauth.active) {
+13 -17
View File
@@ -16,14 +16,10 @@ 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 { import { useOIDCParams } from "@/lib/hooks/oidc";
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for";
export const TotpPage = () => { export const TotpPage = () => {
const { totp, auth } = useUserContext(); const { totp } = useUserContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation(); const { search } = useLocation();
const formId = useId(); const formId = useId();
@@ -31,12 +27,8 @@ 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 screenParams = useScreenParams(searchParams); const redirectUri = searchParams.get("redirect_uri") || undefined;
const compiledParams = recompileScreenParams(screenParams); const oidcParams = useOIDCParams(searchParams);
const loginForUrl = useLoginFor({
login_for: screenParams.login_for,
compiledParams,
});
const totpMutation = useMutation({ const totpMutation = useMutation({
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values), mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
@@ -47,7 +39,14 @@ export const TotpPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.replace(loginForUrl); if (oidcParams.isOidc) {
window.location.replace(`/authorize?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
}, 500); }, 500);
}, },
onError: () => { onError: () => {
@@ -66,10 +65,7 @@ export const TotpPage = () => {
}, [redirectTimer]); }, [redirectTimer]);
if (!totp.pending) { if (!totp.pending) {
if (auth.authenticated) { return <Navigate to="/" replace />;
return <Navigate to={loginForUrl} replace />;
}
return <Navigate to={`/login${compiledParams}`} replace />;
} }
return ( return (
+5
View File
@@ -0,0 +1,5 @@
import { z } from "zod";
export const getOidcClientInfoSchema = z.object({
name: z.string(),
});
-5
View File
@@ -57,11 +57,6 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/robots.txt/, ""), rewrite: (path) => path.replace(/^\/robots.txt/, ""),
}, },
"/authorize": {
target: "http://tinyauth-backend:3000/authorize",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/authorize/, ""),
},
}, },
allowedHosts: true, allowedHosts: true,
}, },
-1
View File
@@ -9,7 +9,6 @@ require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/go-querystring v1.2.0 github.com/google/go-querystring v1.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
-2
View File
@@ -216,8 +216,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
+1 -1
View File
@@ -59,7 +59,7 @@ func (app *BootstrapApp) setupRouter() error {
controller.NewContextController(app.log, app.config, app.runtime, apiRouter) controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService) controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter, &engine.RouterGroup) controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter)
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService, app.services.policyEngine) controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService, app.services.policyEngine)
controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService) controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
controller.NewResourcesController(app.config, &engine.RouterGroup) controller.NewResourcesController(app.config, &engine.RouterGroup)
-8
View File
@@ -1,12 +1,5 @@
package controller package controller
type FrontendLoginFor string
const (
FrontendLoginForOIDC FrontendLoginFor = "oidc"
FrontendLoginForApp FrontendLoginFor = "app"
)
type UnauthorizedQuery struct { type UnauthorizedQuery struct {
Username string `url:"username"` Username string `url:"username"`
Resource string `url:"resource"` Resource string `url:"resource"`
@@ -16,5 +9,4 @@ type UnauthorizedQuery struct {
type RedirectQuery struct { type RedirectQuery struct {
RedirectURI string `url:"redirect_uri"` RedirectURI string `url:"redirect_uri"`
LoginFor FrontendLoginFor `url:"login_for"`
} }
+8 -6
View File
@@ -61,7 +61,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
return return
} }
var reqParams service.OAuthCallbackParams var reqParams service.OAuthURLParams
err = c.BindQuery(&reqParams) err = c.BindQuery(&reqParams)
@@ -83,7 +83,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
} }
} }
sessionId, err := controller.auth.NewOAuthSession(req.Provider, reqParams) sessionId, _, err := controller.auth.NewOAuthSession(req.Provider, reqParams)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session") controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session")
@@ -272,14 +272,13 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return return
} }
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/oidc/authorize?%s", controller.runtime.AppURL, queries.Encode())) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/authorize?%s", controller.runtime.AppURL, queries.Encode()))
return return
} }
if oauthPendingSession.CallbackParams.RedirectURI != "" { if oauthPendingSession.CallbackParams.RedirectURI != "" {
queries, err := query.Values(RedirectQuery{ queries, err := query.Values(RedirectQuery{
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI, RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
LoginFor: FrontendLoginForApp,
}) })
if err != nil { if err != nil {
@@ -295,8 +294,11 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, controller.runtime.AppURL) c.Redirect(http.StatusTemporaryRedirect, controller.runtime.AppURL)
} }
func (controller *OAuthController) isOidcRequest(params service.OAuthCallbackParams) bool { func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams) bool {
return params.LoginFor == string(FrontendLoginForOIDC) return params.Scope != "" &&
params.ResponseType != "" &&
params.ClientID != "" &&
params.RedirectURI != ""
} }
func (controller *OAuthController) getCookieDomain() string { func (controller *OAuthController) getCookieDomain() string {
+80 -191
View File
@@ -9,7 +9,6 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
@@ -24,7 +23,6 @@ type authorizeErrorParams struct {
callback string callback string
callbackError string callbackError string
state string state string
json bool
} }
type OIDCController struct { type OIDCController struct {
@@ -67,34 +65,20 @@ type ClientCredentials struct {
ClientSecret string ClientSecret string
} }
type AuthorizeScreenParams struct {
LoginFor FrontendLoginFor `url:"login_for"`
OIDCTicket string `url:"oidc_ticket"`
OIDCScope string `url:"oidc_scope"`
OIDCName string `url:"oidc_name"`
}
type AuthorizeCompleteRequest struct {
Ticket string `json:"ticket" binding:"required"`
}
func NewOIDCController( func NewOIDCController(
log *logger.Logger, log *logger.Logger,
oidcService *service.OIDCService, oidcService *service.OIDCService,
runtimeConfig model.RuntimeConfig, runtimeConfig model.RuntimeConfig,
router *gin.RouterGroup, router *gin.RouterGroup) *OIDCController {
mainRouter *gin.RouterGroup) *OIDCController {
controller := &OIDCController{ controller := &OIDCController{
log: log, log: log,
oidc: oidcService, oidc: oidcService,
runtime: runtimeConfig, runtime: runtimeConfig,
} }
mainRouter.POST("/authorize", controller.authorize)
mainRouter.GET("/authorize", controller.authorize)
oidcGroup := router.Group("/oidc") oidcGroup := router.Group("/oidc")
oidcGroup.POST("/authorize-complete", controller.authorizeComplete) oidcGroup.GET("/clients/:id", controller.GetClientInfo)
oidcGroup.POST("/authorize", controller.Authorize)
oidcGroup.POST("/token", controller.Token) oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo) oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo) oidcGroup.POST("/userinfo", controller.Userinfo)
@@ -102,10 +86,47 @@ func NewOIDCController(
return controller return controller
} }
// This endpoint does **not** return a code, it handles param validation, ticket creation func (controller *OIDCController) GetClientInfo(c *gin.Context) {
// and then redirects to the frontend to handle the consent screen. It performs no destructive if controller.oidc == nil {
// actions (like logging out an existing session) controller.log.App.Warn().Msg("Received OIDC client info request but OIDC server is not configured")
func (controller *OIDCController) authorize(c *gin.Context) { c.JSON(500, gin.H{
"status": 500,
"message": "OIDC not configured",
})
return
}
var req ClientRequest
err := c.BindUri(&req)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
client, ok := controller.oidc.GetClient(req.ClientID)
if !ok {
controller.log.App.Warn().Str("clientId", req.ClientID).Msg("Client not found")
c.JSON(404, gin.H{
"status": 404,
"message": "Client not found",
})
return
}
c.JSON(200, gin.H{
"status": 200,
"client": client.ClientID,
"name": client.Name,
})
}
func (controller *OIDCController) Authorize(c *gin.Context) {
if controller.oidc == nil { if controller.oidc == nil {
controller.authorizeError(c, authorizeErrorParams{ controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err_oidc_not_configured"), err: errors.New("err_oidc_not_configured"),
@@ -115,19 +136,40 @@ func (controller *OIDCController) authorize(c *gin.Context) {
return return
} }
req, err := controller.resolveAuthorizeRequest(c) userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil { if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to resolve authorize request")
controller.authorizeError(c, authorizeErrorParams{ controller.authorizeError(c, authorizeErrorParams{
err: err, err: err,
reason: "Failed to resolve authorize request", reason: "Failed to get user context",
reasonPublic: "The authorization request is invalid", reasonPublic: "User is not logged in or the session is invalid",
}) })
return return
} }
client, ok := controller.oidc.GetClient(req.ClientID) if !userContext.Authenticated {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err user not logged in"),
reason: "User not logged in",
reasonPublic: "The user is not logged in",
})
return
}
var req service.AuthorizeRequest
err = c.Bind(&req)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to bind JSON",
reasonPublic: "The client provided an invalid authorization request",
})
return
}
_, ok := controller.oidc.GetClient(req.ClientID)
if !ok { if !ok {
controller.authorizeError(c, authorizeErrorParams{ controller.authorizeError(c, authorizeErrorParams{
@@ -138,7 +180,7 @@ func (controller *OIDCController) authorize(c *gin.Context) {
return return
} }
err = controller.oidc.ValidateAuthorizeParams(*req) err = controller.oidc.ValidateAuthorizeParams(req)
if err != nil { if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params") controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params")
@@ -161,97 +203,8 @@ func (controller *OIDCController) authorize(c *gin.Context) {
return return
} }
ticket := controller.oidc.CreateAuthorizeRequestTicket(*req)
queries, err := query.Values(AuthorizeScreenParams{
LoginFor: FrontendLoginForOIDC,
OIDCTicket: ticket,
OIDCScope: req.Scope,
OIDCName: client.Name,
})
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to compile authorize queries",
reasonPublic: "An internal error occured while processing your request",
})
return
}
redirectUrl := fmt.Sprintf("%s/oidc/authorize?%s", controller.oidc.GetIssuer(), queries.Encode())
c.Redirect(http.StatusFound, redirectUrl)
}
// The actual **internal** endpoint that actually creates the code and session.
// It is called by the frontend after the user has logged in and given consent.
func (controller *OIDCController) authorizeComplete(c *gin.Context) {
if controller.oidc == nil {
// For this endpoint we return JSON errors since it's called
// by the frontend and not an external client, so there's
// no redirect_uri to send the user to in case of error
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err_oidc_not_configured"),
reason: "OIDC not configured",
reasonPublic: "This instance is not configured for OIDC",
json: true,
})
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to get user context",
reasonPublic: "User is not logged in or the session is invalid",
json: true,
})
return
}
if !userContext.Authenticated {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err user not logged in"),
reason: "User not logged in",
reasonPublic: "The user is not logged in",
json: true,
})
return
}
var req AuthorizeCompleteRequest
err = c.BindJSON(&req)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to bind JSON",
reasonPublic: "The client provided an invalid authorization request",
json: true,
})
return
}
authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.Ticket)
if !ok {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("authorize request not found for ticket"),
reason: "Invalid or expired ticket",
reasonPublic: "The authorization request has expired or is invalid",
json: true,
})
return
}
// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
// Create the sub to find and delete old sessions // Create the sub to find and delete old sessions
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID) sub := controller.oidc.CreateSub(*userContext, req.ClientID)
// Before storing the code, delete old session // Before storing the code, delete old session
err = controller.oidc.DeleteOldSession(c, sub) err = controller.oidc.DeleteOldSession(c, sub)
@@ -260,20 +213,19 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
err: err, err: err,
reason: "Failed to delete old sessions", reason: "Failed to delete old sessions",
reasonPublic: "Failed to delete old sessions", reasonPublic: "Failed to delete old sessions",
callback: authorizeReq.RedirectURI, callback: req.RedirectURI,
callbackError: "server_error", callbackError: "server_error",
state: authorizeReq.State, state: req.State,
json: true,
}) })
return return
} }
// Create the authorization code // Create the authorization code
code := controller.oidc.CreateCode(*authorizeReq, *userContext) code := controller.oidc.CreateCode(req, *userContext)
queries, err := query.Values(AuthorizeCallback{ queries, err := query.Values(AuthorizeCallback{
Code: code, Code: code,
State: authorizeReq.State, State: req.State,
}) })
if err != nil { if err != nil {
@@ -281,17 +233,16 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
err: err, err: err,
reason: "Failed to build query", reason: "Failed to build query",
reasonPublic: "Failed to build query", reasonPublic: "Failed to build query",
callback: authorizeReq.RedirectURI, callback: req.RedirectURI,
callbackError: "server_error", callbackError: "server_error",
state: authorizeReq.State, state: req.State,
json: true,
}) })
return return
} }
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()), "redirect_uri": fmt.Sprintf("%s?%s", req.RedirectURI, queries.Encode()),
}) })
} }
@@ -582,25 +533,17 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
queries, err := query.Values(errorQueries) queries, err := query.Values(errorQueries)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to build callback error query")
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)
return return
} }
redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode())
if params.json {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": redirectUrl, "redirect_uri": fmt.Sprintf("%s?%s", params.callback, queries.Encode()),
}) })
return return
} }
c.Redirect(http.StatusFound, redirectUrl)
return
}
errorQueries := ErrorScreen{ errorQueries := ErrorScreen{
Error: params.reasonPublic, Error: params.reasonPublic,
} }
@@ -608,7 +551,6 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
queries, err := query.Values(errorQueries) queries, err := query.Values(errorQueries)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to build error query")
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)
return return
} }
@@ -621,61 +563,8 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
redirectUrl = fmt.Sprintf("%s/error?%s", controller.runtime.AppURL, queries.Encode()) redirectUrl = fmt.Sprintf("%s/error?%s", controller.runtime.AppURL, queries.Encode())
} }
if params.json {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": redirectUrl, "redirect_uri": redirectUrl,
}) })
return
}
c.Redirect(http.StatusFound, redirectUrl)
}
func (controller *OIDCController) resolveAuthorizeRequest(c *gin.Context) (*service.AuthorizeRequest, error) {
// step 1: if we have a request object, decode it and ignore other params. If not, bind the params as usual
// we check both query and form parameters for the request object since this endpoint can be called with both GET and POST
requestObject, err := controller.resolveRequestObject(c)
if err != nil {
return nil, err
}
if requestObject != nil {
return requestObject, nil
}
// step 2: by default we assume normal GET query parameters
// step 3: if it's a POST request, we try form parameters
return controller.resolveNormalParams(c)
}
func (controller *OIDCController) resolveRequestObject(c *gin.Context) (*service.AuthorizeRequest, error) {
raw := c.Query("request")
if raw == "" && c.Request.Method == http.MethodPost {
raw = c.PostForm("request")
}
if raw == "" {
return nil, nil
}
return controller.oidc.DecodeAuthorizeJWT(raw)
}
func (controller *OIDCController) resolveNormalParams(c *gin.Context) (*service.AuthorizeRequest, error) {
var req service.AuthorizeRequest
bind := binding.Query
if c.Request.Method == http.MethodPost {
bind = binding.Form
}
if err := c.ShouldBindWith(&req, bind); err != nil {
return nil, err
}
return &req, nil
} }
File diff suppressed because it is too large Load Diff
-1
View File
@@ -275,7 +275,6 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
queries, err := query.Values(RedirectQuery{ queries, err := query.Values(RedirectQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path), RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
LoginFor: FrontendLoginForApp,
}) })
if err != nil { if err != nil {
+6 -19
View File
@@ -3,7 +3,6 @@ package controller_test
import ( import (
"context" "context"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -77,9 +76,7 @@ func TestProxyController(t *testing.T) {
assert.Equal(t, 307, recorder.Code) assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location") location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -92,9 +89,7 @@ func TestProxyController(t *testing.T) {
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code) assert.Equal(t, 401, recorder.Code)
location := recorder.Header().Get("x-tinyauth-location") location := recorder.Header().Get("x-tinyauth-location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -108,9 +103,7 @@ func TestProxyController(t *testing.T) {
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, 307, recorder.Code) assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location") location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/hello")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2Fhello", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -126,9 +119,7 @@ func TestProxyController(t *testing.T) {
assert.Equal(t, 307, recorder.Code) assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location") location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -143,9 +134,7 @@ func TestProxyController(t *testing.T) {
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code) assert.Equal(t, 401, recorder.Code)
location := recorder.Header().Get("x-tinyauth-location") location := recorder.Header().Get("x-tinyauth-location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -161,9 +150,7 @@ func TestProxyController(t *testing.T) {
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, 307, recorder.Code) assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location") location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2Fhello", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
+1 -1
View File
@@ -38,7 +38,7 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc {
path := strings.TrimPrefix(c.Request.URL.Path, "/") path := strings.TrimPrefix(c.Request.URL.Path, "/")
switch strings.SplitN(path, "/", 2)[0] { switch strings.SplitN(path, "/", 2)[0] {
case "api", "resources", ".well-known", "authorize": case "api", "resources", ".well-known":
c.Next() c.Next()
return return
case "robots.txt": case "robots.txt":
+1
View File
@@ -181,6 +181,7 @@ type LDAPConfig struct {
Address string `description:"LDAP server address." yaml:"address"` Address string `description:"LDAP server address." yaml:"address"`
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile"`
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
+15 -12
View File
@@ -30,14 +30,17 @@ var (
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
) )
// We either store params for redirecting to an app after OAuth login, // slightly modified version of the AuthorizeRequest from the OIDC service to basically accept all
// or for redirecting back to the authorize screen to continue OIDC // parameters and pass them to the authorize page if needed
type OAuthCallbackParams struct { type OAuthURLParams struct {
LoginFor string `form:"login_for" url:"login_for"` Scope string `form:"scope" url:"scope"`
OIDCTicket string `form:"oidc_ticket" url:"oidc_ticket"` ResponseType string `form:"response_type" url:"response_type"`
OIDCScope string `form:"oidc_scope" url:"oidc_scope"` ClientID string `form:"client_id" url:"client_id"`
OIDCName string `form:"oidc_name" url:"oidc_name"`
RedirectURI string `form:"redirect_uri" url:"redirect_uri"` RedirectURI string `form:"redirect_uri" url:"redirect_uri"`
State string `form:"state" url:"state"`
Nonce string `form:"nonce" url:"nonce"`
CodeChallenge string `form:"code_challenge" url:"code_challenge"`
CodeChallengeMethod string `form:"code_challenge_method" url:"code_challenge_method"`
} }
type OAuthPendingSession struct { type OAuthPendingSession struct {
@@ -46,7 +49,7 @@ type OAuthPendingSession struct {
Token *oauth2.Token Token *oauth2.Token
Service *OAuthServiceImpl Service *OAuthServiceImpl
ExpiresAt time.Time ExpiresAt time.Time
CallbackParams OAuthCallbackParams CallbackParams OAuthURLParams
} }
type LoginAttempt struct { type LoginAttempt struct {
@@ -513,17 +516,17 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
return auth.ldap != nil return auth.ldap != nil
} }
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthCallbackParams) (string, error) { func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
service, ok := auth.oauthBroker.GetService(serviceName) service, ok := auth.oauthBroker.GetService(serviceName)
if !ok { if !ok {
return "", fmt.Errorf("oauth service not found: %s", serviceName) return "", OAuthPendingSession{}, fmt.Errorf("oauth service not found: %s", serviceName)
} }
sessionId, err := uuid.NewRandom() sessionId, err := uuid.NewRandom()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to generate session ID: %w", err) return "", OAuthPendingSession{}, fmt.Errorf("failed to generate session ID: %w", err)
} }
state := service.NewRandom() state := service.NewRandom()
@@ -539,7 +542,7 @@ func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthCallbac
auth.caches.oauth.Set(sessionId.String(), session, time.Minute*10) auth.caches.oauth.Set(sessionId.String(), session, time.Minute*10)
return sessionId.String(), nil return sessionId.String(), session, nil
} }
func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) { func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
+5
View File
@@ -11,6 +11,7 @@ import (
ldapgo "github.com/go-ldap/ldap/v3" ldapgo "github.com/go-ldap/ldap/v3"
"github.com/steveiliop56/ding" "github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
@@ -32,6 +33,10 @@ func NewLdapService(
return nil, nil return nil, nil
} }
secret := utils.GetSecret(config.LDAP.BindPassword, config.LDAP.BindPasswordFile)
config.LDAP.BindPassword = secret
config.LDAP.BindPasswordFile = ""
ldap := &LdapService{ ldap := &LdapService{
log: log, log: log,
config: config, config: config,
+8 -56
View File
@@ -20,7 +20,6 @@ import (
"slices" "slices"
"github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/steveiliop56/ding" "github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
@@ -107,15 +106,14 @@ type TokenResponse struct {
} }
type AuthorizeRequest struct { type AuthorizeRequest struct {
jwt.Claims Scope string `json:"scope" binding:"required"`
Scope string `form:"scope" json:"scope" url:"scope"` ResponseType string `json:"response_type" binding:"required"`
ResponseType string `form:"response_type" json:"response_type" url:"response_type"` ClientID string `json:"client_id" binding:"required"`
ClientID string `form:"client_id" json:"client_id" url:"client_id"` RedirectURI string `json:"redirect_uri" binding:"required"`
RedirectURI string `form:"redirect_uri" json:"redirect_uri" url:"redirect_uri"` State string `json:"state"`
State string `form:"state" json:"state" url:"state"` Nonce string `json:"nonce"`
Nonce string `form:"nonce" json:"nonce" url:"nonce"` CodeChallenge string `json:"code_challenge"`
CodeChallenge string `form:"code_challenge" json:"code_challenge" url:"code_challenge"` CodeChallengeMethod string `json:"code_challenge_method"`
CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" url:"code_challenge_method"`
} }
type AuthorizeCodeEntry struct { type AuthorizeCodeEntry struct {
@@ -146,7 +144,6 @@ type OIDCService struct {
caches struct { caches struct {
code *CacheStore[AuthorizeCodeEntry] code *CacheStore[AuthorizeCodeEntry]
usedCode *CacheStore[UsedCodeEntry] usedCode *CacheStore[UsedCodeEntry]
authorize *CacheStore[AuthorizeRequest]
} }
} }
@@ -314,11 +311,8 @@ func NewOIDCService(
// Create caches // Create caches
codeCash := NewCacheStore[AuthorizeCodeEntry](256) codeCash := NewCacheStore[AuthorizeCodeEntry](256)
usedCode := NewCacheStore[UsedCodeEntry](256) usedCode := NewCacheStore[UsedCodeEntry](256)
authorize := NewCacheStore[AuthorizeRequest](256)
service.caches.code = codeCash service.caches.code = codeCash
service.caches.usedCode = usedCode service.caches.usedCode = usedCode
service.caches.authorize = authorize
// Start cache cleanup routine // Start cache cleanup routine
dg.Go(func(ctx context.Context) { dg.Go(func(ctx context.Context) {
@@ -330,7 +324,6 @@ func NewOIDCService(
case <-ticker.C: case <-ticker.C:
service.caches.code.Sweep() service.caches.code.Sweep()
service.caches.usedCode.Sweep() service.caches.usedCode.Sweep()
service.caches.authorize.Sweep()
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -863,44 +856,3 @@ func (service *OIDCService) MarkCodeAsUsed(codeHash string, sub string) {
func (service *OIDCService) DeleteSessionBySub(ctx context.Context, sub string) error { func (service *OIDCService) DeleteSessionBySub(ctx context.Context, sub string) error {
return service.queries.DeleteOIDCSessionBySub(ctx, sub) return service.queries.DeleteOIDCSessionBySub(ctx, sub)
} }
func (service *OIDCService) CreateAuthorizeRequestTicket(req AuthorizeRequest) string {
ticket := utils.GenerateString(32)
service.caches.authorize.Set(ticket, req, 10*time.Minute)
return ticket
}
func (service *OIDCService) GetAuthorizeRequestByTicket(ticket string) (*AuthorizeRequest, bool) {
entry, ok := service.caches.authorize.Get(ticket)
if !ok {
return nil, false
}
return &entry, true
}
func (service *OIDCService) DeleteAuthorizeRequestTicket(ticket string) {
service.caches.authorize.Delete(ticket)
}
// TODO: support signed request objects in the future
func (service *OIDCService) DecodeAuthorizeJWT(tokenString string) (*AuthorizeRequest, error) {
var req AuthorizeRequest
token, _, err := jwt.NewParser().ParseUnverified(tokenString, &req)
if err != nil {
return nil, fmt.Errorf("failed to parse authorize request jwt: %w", err)
}
claims, ok := token.Claims.(*AuthorizeRequest)
if !ok {
return nil, errors.New("failed to parse claims from authorize request jwt")
}
return claims, nil
}