mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-06-08 12:30:17 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c9817cf39 | |||
| ede6e8084d | |||
| 4e671ed48c | |||
| a69d22bb0e | |||
| ace64fa7ee | |||
| 5e954da5ff | |||
| 47b7f1e6f2 | |||
| f078e3549e | |||
| da9079246a | |||
| 2454ba58ea | |||
| 97e0e0dfff | |||
| b3c152fa1c | |||
| 5caee887de | |||
| b5770ef305 | |||
| 1c4ca8f436 | |||
| a72300484b | |||
| 4fe5de241b | |||
| 83ed9ece57 | |||
| faa3156672 | |||
| 695feca71c | |||
| 82d21c3b28 | |||
| fe8463890a | |||
| ac9689dc9b | |||
| 3e5757cfc9 | |||
| ed94490efd |
@@ -62,6 +62,6 @@ jobs:
|
||||
run: go test -coverprofile=coverage.txt -v ./...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useAppContext } from "@/context/app-context";
|
||||
import { LanguageSelector } from "../language/language";
|
||||
import { Outlet } from "react-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DomainWarning } from "../domain-warning/domain-warning";
|
||||
import { ThemeToggle } from "../theme-toggle/theme-toggle";
|
||||
import { QuickActions } from "../quick-actions/quick-actions";
|
||||
|
||||
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const { ui } = useAppContext();
|
||||
@@ -21,9 +20,8 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-4 right-4 flex flex-row gap-2">
|
||||
<ThemeToggle />
|
||||
<LanguageSelector />
|
||||
<div className="absolute top-4 right-4">
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div className="max-w-sm md:min-w-sm min-w-xs">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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 }
|
||||
@@ -0,0 +1,17 @@
|
||||
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";
|
||||
}
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
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: "",
|
||||
};
|
||||
};
|
||||
@@ -7,7 +7,7 @@ type IuseRedirectUri = {
|
||||
};
|
||||
|
||||
export const useRedirectUri = (
|
||||
redirect_uri: string | null,
|
||||
redirect_uri: string | undefined,
|
||||
cookieDomain: string,
|
||||
): IuseRedirectUri => {
|
||||
let isValid = false;
|
||||
@@ -15,7 +15,7 @@ export const useRedirectUri = (
|
||||
let isAllowedProto = false;
|
||||
let isHttpsDowngrade = false;
|
||||
|
||||
if (!redirect_uri) {
|
||||
if (redirect_uri === undefined) {
|
||||
return {
|
||||
valid: isValid,
|
||||
trusted: isTrusted,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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,96 +1,102 @@
|
||||
{
|
||||
"loginTitle": "Welcome back, login with",
|
||||
"loginTitleSimple": "Welcome back, please login",
|
||||
"loginDivider": "Or",
|
||||
"loginUsername": "Username",
|
||||
"loginPassword": "Password",
|
||||
"loginSubmit": "Login",
|
||||
"loginFailTitle": "Failed to log in",
|
||||
"loginFailSubtitle": "Please check your username and password",
|
||||
"loginFailRateLimit": "You failed to login too many times. Please try again later",
|
||||
"loginSuccessTitle": "Logged in",
|
||||
"loginSuccessSubtitle": "Welcome back!",
|
||||
"loginOauthFailTitle": "An error occurred",
|
||||
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
||||
"loginOauthSuccessTitle": "Redirecting",
|
||||
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
||||
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
|
||||
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
|
||||
"loginOauthAutoRedirectButton": "Redirect now",
|
||||
"continueTitle": "Continue",
|
||||
"continueRedirectingTitle": "Redirecting...",
|
||||
"continueRedirectingSubtitle": "You should be redirected to the app soon",
|
||||
"continueRedirectManually": "Redirect me manually",
|
||||
"continueInsecureRedirectTitle": "Insecure redirect",
|
||||
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
|
||||
"continueUntrustedRedirectTitle": "Untrusted redirect",
|
||||
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
|
||||
"logoutFailTitle": "Failed to log out",
|
||||
"logoutFailSubtitle": "Please try again",
|
||||
"logoutSuccessTitle": "Logged out",
|
||||
"logoutSuccessSubtitle": "You have been logged out",
|
||||
"logoutTitle": "Logout",
|
||||
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
|
||||
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
|
||||
"notFoundTitle": "Page not found",
|
||||
"notFoundSubtitle": "The page you are looking for does not exist.",
|
||||
"notFoundButton": "Go home",
|
||||
"totpFailTitle": "Failed to verify code",
|
||||
"totpFailSubtitle": "Please check your code and try again",
|
||||
"totpSuccessTitle": "Verified",
|
||||
"totpSuccessSubtitle": "Redirecting to your app",
|
||||
"totpTitle": "Enter your TOTP code",
|
||||
"totpSubtitle": "Please enter the code from your authenticator app.",
|
||||
"unauthorizedTitle": "Unauthorized",
|
||||
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
|
||||
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedButton": "Try again",
|
||||
"cancelTitle": "Cancel",
|
||||
"forgotPasswordTitle": "Forgot your password?",
|
||||
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
|
||||
"errorTitle": "An error occurred",
|
||||
"errorSubtitleInfo": "The following error occurred while processing your request:",
|
||||
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
|
||||
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
|
||||
"fieldRequired": "This field is required",
|
||||
"invalidInput": "Invalid input",
|
||||
"domainWarningTitle": "Invalid Domain",
|
||||
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
|
||||
"domainWarningCurrent": "Current:",
|
||||
"domainWarningExpected": "Expected:",
|
||||
"ignoreTitle": "Ignore",
|
||||
"goToCorrectDomainTitle": "Go to correct domain",
|
||||
"authorizeTitle": "Authorize",
|
||||
"authorizeCardTitle": "Continue to {{app}}?",
|
||||
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
|
||||
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
|
||||
"authorizeLoadingTitle": "Loading...",
|
||||
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
|
||||
"authorizeSuccessTitle": "Authorized",
|
||||
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
|
||||
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
|
||||
"authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
|
||||
"openidScopeName": "OpenID Connect",
|
||||
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
|
||||
"emailScopeName": "Email",
|
||||
"emailScopeDescription": "Allows the app to access your email address.",
|
||||
"profileScopeName": "Profile",
|
||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||
"groupsScopeName": "Groups",
|
||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
||||
"backToLoginButton": "Back to login",
|
||||
"phoneScopeName": "Phone",
|
||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
||||
"addressScopeName": "Address",
|
||||
"addressScopeDescription": "Allows the app to access your address.",
|
||||
"loginTailscaleTitle": "Continue with Tailscale",
|
||||
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
|
||||
"loginTailscaleDeviceName": "Device name:",
|
||||
"loginTailscaleSubmit": "Continue with Tailscale",
|
||||
"loginTailscaleOtherMethod": "Login with another method",
|
||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
||||
"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."
|
||||
"loginTitle": "Welcome back, login with",
|
||||
"loginTitleSimple": "Welcome back, please login",
|
||||
"loginDivider": "Or",
|
||||
"loginUsername": "Username",
|
||||
"loginPassword": "Password",
|
||||
"loginSubmit": "Login",
|
||||
"loginFailTitle": "Failed to log in",
|
||||
"loginFailSubtitle": "Please check your username and password",
|
||||
"loginFailRateLimit": "You failed to login too many times. Please try again later",
|
||||
"loginSuccessTitle": "Logged in",
|
||||
"loginSuccessSubtitle": "Welcome back!",
|
||||
"loginOauthFailTitle": "An error occurred",
|
||||
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
||||
"loginOauthSuccessTitle": "Redirecting",
|
||||
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
||||
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
|
||||
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
|
||||
"loginOauthAutoRedirectButton": "Redirect now",
|
||||
"continueTitle": "Continue",
|
||||
"continueRedirectingTitle": "Redirecting...",
|
||||
"continueRedirectingSubtitle": "You should be redirected to the app soon",
|
||||
"continueRedirectManually": "Redirect me manually",
|
||||
"continueInsecureRedirectTitle": "Insecure redirect",
|
||||
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
|
||||
"continueUntrustedRedirectTitle": "Untrusted redirect",
|
||||
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
|
||||
"logoutFailTitle": "Failed to log out",
|
||||
"logoutFailSubtitle": "Please try again",
|
||||
"logoutSuccessTitle": "Logged out",
|
||||
"logoutSuccessSubtitle": "You have been logged out",
|
||||
"logoutTitle": "Logout",
|
||||
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
|
||||
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
|
||||
"notFoundTitle": "Page not found",
|
||||
"notFoundSubtitle": "The page you are looking for does not exist.",
|
||||
"notFoundButton": "Go home",
|
||||
"totpFailTitle": "Failed to verify code",
|
||||
"totpFailSubtitle": "Please check your code and try again",
|
||||
"totpSuccessTitle": "Verified",
|
||||
"totpSuccessSubtitle": "Redirecting to your app",
|
||||
"totpTitle": "Enter your TOTP code",
|
||||
"totpSubtitle": "Please enter the code from your authenticator app.",
|
||||
"unauthorizedTitle": "Unauthorized",
|
||||
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
|
||||
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedButton": "Try again",
|
||||
"cancelTitle": "Cancel",
|
||||
"forgotPasswordTitle": "Forgot your password?",
|
||||
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
|
||||
"errorTitle": "An error occurred",
|
||||
"errorSubtitleInfo": "The following error occurred while processing your request:",
|
||||
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
|
||||
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
|
||||
"fieldRequired": "This field is required",
|
||||
"invalidInput": "Invalid input",
|
||||
"domainWarningTitle": "Invalid Domain",
|
||||
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
|
||||
"domainWarningCurrent": "Current:",
|
||||
"domainWarningExpected": "Expected:",
|
||||
"ignoreTitle": "Ignore",
|
||||
"goToCorrectDomainTitle": "Go to correct domain",
|
||||
"authorizeTitle": "Authorize",
|
||||
"authorizeCardTitle": "Continue to {{app}}?",
|
||||
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
|
||||
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
|
||||
"authorizeLoadingTitle": "Loading...",
|
||||
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
|
||||
"authorizeSuccessTitle": "Authorized",
|
||||
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
|
||||
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
|
||||
"authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
|
||||
"openidScopeName": "OpenID Connect",
|
||||
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
|
||||
"emailScopeName": "Email",
|
||||
"emailScopeDescription": "Allows the app to access your email address.",
|
||||
"profileScopeName": "Profile",
|
||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||
"groupsScopeName": "Groups",
|
||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
||||
"backToLoginButton": "Back to login",
|
||||
"phoneScopeName": "Phone",
|
||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
||||
"addressScopeName": "Address",
|
||||
"addressScopeDescription": "Allows the app to access your address.",
|
||||
"loginTailscaleTitle": "Continue with Tailscale",
|
||||
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
|
||||
"loginTailscaleDeviceName": "Device name:",
|
||||
"loginTailscaleSubmit": "Continue with Tailscale",
|
||||
"loginTailscaleOtherMethod": "Login with another method",
|
||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
||||
"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.",
|
||||
"quickActionsLanguage": "Language",
|
||||
"quickActionsTheme": "Theme",
|
||||
"quickActionsThemeLight": "Light",
|
||||
"quickActionsThemeDark": "Dark",
|
||||
"quickActionsThemeSystem": "System",
|
||||
"quickActionsLogout": "Logout"
|
||||
}
|
||||
|
||||
@@ -1,96 +1,102 @@
|
||||
{
|
||||
"loginTitle": "Welcome back, login with",
|
||||
"loginTitleSimple": "Welcome back, please login",
|
||||
"loginDivider": "Or",
|
||||
"loginUsername": "Username",
|
||||
"loginPassword": "Password",
|
||||
"loginSubmit": "Login",
|
||||
"loginFailTitle": "Failed to log in",
|
||||
"loginFailSubtitle": "Please check your username and password",
|
||||
"loginFailRateLimit": "You failed to login too many times. Please try again later",
|
||||
"loginSuccessTitle": "Logged in",
|
||||
"loginSuccessSubtitle": "Welcome back!",
|
||||
"loginOauthFailTitle": "An error occurred",
|
||||
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
||||
"loginOauthSuccessTitle": "Redirecting",
|
||||
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
||||
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
|
||||
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
|
||||
"loginOauthAutoRedirectButton": "Redirect now",
|
||||
"continueTitle": "Continue",
|
||||
"continueRedirectingTitle": "Redirecting...",
|
||||
"continueRedirectingSubtitle": "You should be redirected to the app soon",
|
||||
"continueRedirectManually": "Redirect me manually",
|
||||
"continueInsecureRedirectTitle": "Insecure redirect",
|
||||
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
|
||||
"continueUntrustedRedirectTitle": "Untrusted redirect",
|
||||
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
|
||||
"logoutFailTitle": "Failed to log out",
|
||||
"logoutFailSubtitle": "Please try again",
|
||||
"logoutSuccessTitle": "Logged out",
|
||||
"logoutSuccessSubtitle": "You have been logged out",
|
||||
"logoutTitle": "Logout",
|
||||
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
|
||||
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
|
||||
"notFoundTitle": "Page not found",
|
||||
"notFoundSubtitle": "The page you are looking for does not exist.",
|
||||
"notFoundButton": "Go home",
|
||||
"totpFailTitle": "Failed to verify code",
|
||||
"totpFailSubtitle": "Please check your code and try again",
|
||||
"totpSuccessTitle": "Verified",
|
||||
"totpSuccessSubtitle": "Redirecting to your app",
|
||||
"totpTitle": "Enter your TOTP code",
|
||||
"totpSubtitle": "Please enter the code from your authenticator app.",
|
||||
"unauthorizedTitle": "Unauthorized",
|
||||
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
|
||||
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedButton": "Try again",
|
||||
"cancelTitle": "Cancel",
|
||||
"forgotPasswordTitle": "Forgot your password?",
|
||||
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
|
||||
"errorTitle": "An error occurred",
|
||||
"errorSubtitleInfo": "The following error occurred while processing your request:",
|
||||
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
|
||||
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
|
||||
"fieldRequired": "This field is required",
|
||||
"invalidInput": "Invalid input",
|
||||
"domainWarningTitle": "Invalid Domain",
|
||||
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
|
||||
"domainWarningCurrent": "Current:",
|
||||
"domainWarningExpected": "Expected:",
|
||||
"ignoreTitle": "Ignore",
|
||||
"goToCorrectDomainTitle": "Go to correct domain",
|
||||
"authorizeTitle": "Authorize",
|
||||
"authorizeCardTitle": "Continue to {{app}}?",
|
||||
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
|
||||
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
|
||||
"authorizeLoadingTitle": "Loading...",
|
||||
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
|
||||
"authorizeSuccessTitle": "Authorized",
|
||||
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
|
||||
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
|
||||
"authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
|
||||
"openidScopeName": "OpenID Connect",
|
||||
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
|
||||
"emailScopeName": "Email",
|
||||
"emailScopeDescription": "Allows the app to access your email address.",
|
||||
"profileScopeName": "Profile",
|
||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||
"groupsScopeName": "Groups",
|
||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
||||
"backToLoginButton": "Back to login",
|
||||
"phoneScopeName": "Phone",
|
||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
||||
"addressScopeName": "Address",
|
||||
"addressScopeDescription": "Allows the app to access your address.",
|
||||
"loginTailscaleTitle": "Continue with Tailscale",
|
||||
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
|
||||
"loginTailscaleDeviceName": "Device name:",
|
||||
"loginTailscaleSubmit": "Continue with Tailscale",
|
||||
"loginTailscaleOtherMethod": "Login with another method",
|
||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
||||
"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."
|
||||
"loginTitle": "Welcome back, login with",
|
||||
"loginTitleSimple": "Welcome back, please login",
|
||||
"loginDivider": "Or",
|
||||
"loginUsername": "Username",
|
||||
"loginPassword": "Password",
|
||||
"loginSubmit": "Login",
|
||||
"loginFailTitle": "Failed to log in",
|
||||
"loginFailSubtitle": "Please check your username and password",
|
||||
"loginFailRateLimit": "You failed to login too many times. Please try again later",
|
||||
"loginSuccessTitle": "Logged in",
|
||||
"loginSuccessSubtitle": "Welcome back!",
|
||||
"loginOauthFailTitle": "An error occurred",
|
||||
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
||||
"loginOauthSuccessTitle": "Redirecting",
|
||||
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
||||
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
|
||||
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
|
||||
"loginOauthAutoRedirectButton": "Redirect now",
|
||||
"continueTitle": "Continue",
|
||||
"continueRedirectingTitle": "Redirecting...",
|
||||
"continueRedirectingSubtitle": "You should be redirected to the app soon",
|
||||
"continueRedirectManually": "Redirect me manually",
|
||||
"continueInsecureRedirectTitle": "Insecure redirect",
|
||||
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
|
||||
"continueUntrustedRedirectTitle": "Untrusted redirect",
|
||||
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
|
||||
"logoutFailTitle": "Failed to log out",
|
||||
"logoutFailSubtitle": "Please try again",
|
||||
"logoutSuccessTitle": "Logged out",
|
||||
"logoutSuccessSubtitle": "You have been logged out",
|
||||
"logoutTitle": "Logout",
|
||||
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
|
||||
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
|
||||
"notFoundTitle": "Page not found",
|
||||
"notFoundSubtitle": "The page you are looking for does not exist.",
|
||||
"notFoundButton": "Go home",
|
||||
"totpFailTitle": "Failed to verify code",
|
||||
"totpFailSubtitle": "Please check your code and try again",
|
||||
"totpSuccessTitle": "Verified",
|
||||
"totpSuccessSubtitle": "Redirecting to your app",
|
||||
"totpTitle": "Enter your TOTP code",
|
||||
"totpSubtitle": "Please enter the code from your authenticator app.",
|
||||
"unauthorizedTitle": "Unauthorized",
|
||||
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
|
||||
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
"unauthorizedButton": "Try again",
|
||||
"cancelTitle": "Cancel",
|
||||
"forgotPasswordTitle": "Forgot your password?",
|
||||
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
|
||||
"errorTitle": "An error occurred",
|
||||
"errorSubtitleInfo": "The following error occurred while processing your request:",
|
||||
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
|
||||
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
|
||||
"fieldRequired": "This field is required",
|
||||
"invalidInput": "Invalid input",
|
||||
"domainWarningTitle": "Invalid Domain",
|
||||
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
|
||||
"domainWarningCurrent": "Current:",
|
||||
"domainWarningExpected": "Expected:",
|
||||
"ignoreTitle": "Ignore",
|
||||
"goToCorrectDomainTitle": "Go to correct domain",
|
||||
"authorizeTitle": "Authorize",
|
||||
"authorizeCardTitle": "Continue to {{app}}?",
|
||||
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
|
||||
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
|
||||
"authorizeLoadingTitle": "Loading...",
|
||||
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
|
||||
"authorizeSuccessTitle": "Authorized",
|
||||
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
|
||||
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
|
||||
"authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
|
||||
"openidScopeName": "OpenID Connect",
|
||||
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
|
||||
"emailScopeName": "Email",
|
||||
"emailScopeDescription": "Allows the app to access your email address.",
|
||||
"profileScopeName": "Profile",
|
||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||
"groupsScopeName": "Groups",
|
||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
||||
"backToLoginButton": "Back to login",
|
||||
"phoneScopeName": "Phone",
|
||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
||||
"addressScopeName": "Address",
|
||||
"addressScopeDescription": "Allows the app to access your address.",
|
||||
"loginTailscaleTitle": "Continue with Tailscale",
|
||||
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
|
||||
"loginTailscaleDeviceName": "Device name:",
|
||||
"loginTailscaleSubmit": "Continue with Tailscale",
|
||||
"loginTailscaleOtherMethod": "Login with another method",
|
||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
||||
"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.",
|
||||
"quickActionsLanguage": "Language",
|
||||
"quickActionsTheme": "Theme",
|
||||
"quickActionsThemeLight": "Light",
|
||||
"quickActionsThemeDark": "Dark",
|
||||
"quickActionsThemeSystem": "System",
|
||||
"quickActionsLogout": "Logout"
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route element={<Layout />} errorElement={<ErrorPage />}>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/authorize" element={<AuthorizePage />} />
|
||||
<Route
|
||||
path="/oidc/authorize"
|
||||
element={<AuthorizePage />}
|
||||
/>
|
||||
<Route path="/logout" element={<LogoutPage />} />
|
||||
<Route path="/continue" element={<ContinuePage />} />
|
||||
<Route path="/totp" element={<TotpPage />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useUserContext } from "@/context/user-context";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Navigate, useNavigate } from "react-router";
|
||||
import { useLocation } from "react-router";
|
||||
import {
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
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, MapPin, Phone, Shield, User, Users } from "lucide-react";
|
||||
@@ -23,6 +21,10 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
recompileScreenParams,
|
||||
useScreenParams,
|
||||
} from "@/lib/hooks/screen-params";
|
||||
|
||||
type Scope = {
|
||||
id: string;
|
||||
@@ -84,27 +86,17 @@ export const AuthorizePage = () => {
|
||||
const scopeMap = createScopeMap(t);
|
||||
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const oidcParams = useOIDCParams(searchParams);
|
||||
|
||||
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 screenParams = useScreenParams(searchParams);
|
||||
const isOidc = screenParams.login_for === "oidc";
|
||||
const compiledParams = recompileScreenParams(screenParams);
|
||||
|
||||
const authorizeMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
return axios.post("/api/oidc/authorize", {
|
||||
...oidcParams.values,
|
||||
return axios.post("/api/oidc/authorize-complete", {
|
||||
ticket: screenParams.oidc_ticket,
|
||||
});
|
||||
},
|
||||
mutationKey: ["authorize", oidcParams.values.client_id],
|
||||
mutationKey: ["authorize", screenParams.oidc_ticket],
|
||||
onSuccess: (data) => {
|
||||
toast.info(t("authorizeSuccessTitle"), {
|
||||
description: t("authorizeSuccessSubtitle"),
|
||||
@@ -118,56 +110,38 @@ export const AuthorizePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (oidcParams.issues.length > 0) {
|
||||
if (
|
||||
!isOidc ||
|
||||
screenParams.oidc_ticket === undefined ||
|
||||
screenParams.oidc_scope === undefined
|
||||
) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/error?error=${encodeURIComponent(t("authorizeErrorMissingParams", { missingParams: oidcParams.issues.join(", ") }))}`}
|
||||
to={`/error?error=${encodeURIComponent(t("authorizeErrorInvalidParams"))}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.authenticated) {
|
||||
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
|
||||
/>
|
||||
);
|
||||
return <Navigate to={`/login${compiledParams}`} replace />;
|
||||
}
|
||||
|
||||
const scopes =
|
||||
oidcParams.values.scope.split(" ").filter((s) => s.trim() !== "") || [];
|
||||
screenParams.oidc_scope.split(" ").filter((s) => s.trim() !== "") || [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="mb-2">
|
||||
<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">
|
||||
{getClientInfo.data?.name.slice(0, 1) || "U"}
|
||||
{screenParams.oidc_name !== undefined
|
||||
? screenParams.oidc_name.slice(0, 1)
|
||||
: "U"}
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{t("authorizeCardTitle", {
|
||||
app: getClientInfo.data?.name || "Unknown",
|
||||
app: screenParams.oidc_name || "Unknown",
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm max-w-sm">
|
||||
@@ -206,7 +180,7 @@ export const AuthorizePage = () => {
|
||||
{t("authorizeTitle")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/")}
|
||||
onClick={() => navigate(`/logout${compiledParams}`)}
|
||||
disabled={authorizeMutation.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
|
||||
@@ -12,6 +12,10 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRedirectUri } from "@/lib/hooks/redirect-uri";
|
||||
import {
|
||||
recompileScreenParams,
|
||||
useScreenParams,
|
||||
} from "@/lib/hooks/screen-params";
|
||||
|
||||
export const ContinuePage = () => {
|
||||
const { app, ui } = useAppContext();
|
||||
@@ -25,7 +29,10 @@ export const ContinuePage = () => {
|
||||
const hasRedirected = useRef(false);
|
||||
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const redirectUri = searchParams.get("redirect_uri");
|
||||
const screenParams = useScreenParams(searchParams);
|
||||
const redirectUri = screenParams.redirect_uri;
|
||||
const isAppLogin = screenParams.login_for === "app";
|
||||
const recompiledParams = recompileScreenParams(screenParams);
|
||||
|
||||
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
|
||||
redirectUri,
|
||||
@@ -43,7 +50,8 @@ export const ContinuePage = () => {
|
||||
auth.authenticated &&
|
||||
hasValidRedirect &&
|
||||
!showUntrustedWarning &&
|
||||
!showInsecureWarning;
|
||||
!showInsecureWarning &&
|
||||
isAppLogin;
|
||||
|
||||
const redirectToTarget = useCallback(() => {
|
||||
if (!urlHref || hasRedirected.current) {
|
||||
@@ -79,15 +87,10 @@ export const ContinuePage = () => {
|
||||
}, [shouldAutoRedirect, redirectToTarget]);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
return <Navigate to={`/login${recompiledParams}`} replace />;
|
||||
}
|
||||
|
||||
if (!hasValidRedirect) {
|
||||
if (!hasValidRedirect || !isAppLogin) {
|
||||
return <Navigate to="/logout" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,18 @@ import { useAppContext } from "@/context/app-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Markdown from "react-markdown";
|
||||
import { useLocation } from "react-router";
|
||||
import {
|
||||
recompileScreenParams,
|
||||
useScreenParams,
|
||||
} from "@/lib/hooks/screen-params";
|
||||
|
||||
export const ForgotPasswordPage = () => {
|
||||
const { ui } = useAppContext();
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const screenParams = useScreenParams(searchParams);
|
||||
const compiledParams = recompileScreenParams(screenParams);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -37,10 +43,7 @@ export const ForgotPasswordPage = () => {
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const eparams = searchParams.toString();
|
||||
window.location.replace(
|
||||
`/login${eparams.length > 0 ? `?${eparams}` : ""}`,
|
||||
);
|
||||
window.location.replace(`/login${compiledParams}`);
|
||||
}}
|
||||
>
|
||||
{t("backToLoginButton")}
|
||||
|
||||
@@ -18,7 +18,6 @@ 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";
|
||||
@@ -26,6 +25,11 @@ import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Navigate, useLocation } from "react-router";
|
||||
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> = {
|
||||
google: <GoogleIcon />,
|
||||
@@ -46,7 +50,9 @@ export const LoginPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showRedirectButton, setShowRedirectButton] = useState(false);
|
||||
const [useTailscale, setUseTailscale] = useState(tailscale.nodeName !== undefined);
|
||||
const [useTailscale, setUseTailscale] = useState(
|
||||
tailscale.nodeName !== undefined,
|
||||
);
|
||||
|
||||
const hasAutoRedirectedRef = useRef(false);
|
||||
|
||||
@@ -56,17 +62,22 @@ export const LoginPage = () => {
|
||||
const formId = useId();
|
||||
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const redirectUri = searchParams.get("redirect_uri") || undefined;
|
||||
const oidcParams = useOIDCParams(searchParams);
|
||||
const screenParams = useScreenParams(searchParams);
|
||||
const compiledParams = recompileScreenParams(screenParams);
|
||||
const loginForUrl = useLoginFor({
|
||||
login_for: screenParams.login_for,
|
||||
compiledParams,
|
||||
});
|
||||
|
||||
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
|
||||
providers.find((provider) => provider.id === oauth.autoRedirect) !==
|
||||
undefined && redirectUri !== undefined,
|
||||
undefined && screenParams.redirect_uri !== undefined,
|
||||
);
|
||||
|
||||
const oauthProviders = providers.filter(
|
||||
(provider) => provider.id !== "local" && provider.id !== "ldap",
|
||||
);
|
||||
|
||||
const userAuthConfigured =
|
||||
providers.find(
|
||||
(provider) => provider.id === "local" || provider.id === "ldap",
|
||||
@@ -79,16 +90,7 @@ export const LoginPage = () => {
|
||||
variables: oauthVariables,
|
||||
} = useMutation({
|
||||
mutationFn: (provider: string) => {
|
||||
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()}`);
|
||||
return axios.get(`/api/oauth/url/${provider}${compiledParams}`);
|
||||
},
|
||||
mutationKey: ["oauth"],
|
||||
onSuccess: (data) => {
|
||||
@@ -119,13 +121,7 @@ export const LoginPage = () => {
|
||||
mutationKey: ["login"],
|
||||
onSuccess: (data) => {
|
||||
if (data.data.totpPending) {
|
||||
if (oidcParams.isOidc) {
|
||||
window.location.replace(`/totp?${oidcParams.compiled}`);
|
||||
return;
|
||||
}
|
||||
window.location.replace(
|
||||
`/totp${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
|
||||
);
|
||||
window.location.replace(`/totp${compiledParams}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,13 +130,7 @@ export const LoginPage = () => {
|
||||
});
|
||||
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
if (oidcParams.isOidc) {
|
||||
window.location.replace(`/authorize?${oidcParams.compiled}`);
|
||||
return;
|
||||
}
|
||||
window.location.replace(
|
||||
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
|
||||
);
|
||||
window.location.replace(loginForUrl);
|
||||
}, 500);
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
@@ -163,13 +153,7 @@ export const LoginPage = () => {
|
||||
});
|
||||
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
if (oidcParams.isOidc) {
|
||||
window.location.replace(`/authorize?${oidcParams.compiled}`);
|
||||
return;
|
||||
}
|
||||
window.location.replace(
|
||||
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
|
||||
);
|
||||
window.location.replace(loginForUrl);
|
||||
}, 500);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -184,7 +168,7 @@ export const LoginPage = () => {
|
||||
!auth.authenticated &&
|
||||
isOauthAutoRedirect &&
|
||||
!hasAutoRedirectedRef.current &&
|
||||
redirectUri !== undefined
|
||||
screenParams.login_for !== undefined
|
||||
) {
|
||||
hasAutoRedirectedRef.current = true;
|
||||
oauthMutate(oauth.autoRedirect);
|
||||
@@ -195,7 +179,7 @@ export const LoginPage = () => {
|
||||
hasAutoRedirectedRef,
|
||||
oauth.autoRedirect,
|
||||
isOauthAutoRedirect,
|
||||
redirectUri,
|
||||
screenParams.login_for,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -210,21 +194,8 @@ export const LoginPage = () => {
|
||||
};
|
||||
}, [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) {
|
||||
return <Navigate to="/logout" replace />;
|
||||
return <Navigate to={loginForUrl} replace />;
|
||||
}
|
||||
|
||||
if (isOauthAutoRedirect) {
|
||||
|
||||
@@ -15,12 +15,21 @@ import { Navigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { type UseMutationResult } from "@tanstack/react-query";
|
||||
import { type AxiosResponse } from "axios";
|
||||
import { useLocation } from "react-router";
|
||||
import {
|
||||
useScreenParams,
|
||||
recompileScreenParams,
|
||||
} from "@/lib/hooks/screen-params";
|
||||
|
||||
export const LogoutPage = () => {
|
||||
const { auth, oauth, tailscale } = useUserContext();
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
|
||||
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"),
|
||||
@@ -31,7 +40,7 @@ export const LogoutPage = () => {
|
||||
});
|
||||
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
window.location.replace("/login");
|
||||
window.location.replace(`/login${compiledParams}`);
|
||||
}, 500);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -50,7 +59,7 @@ export const LogoutPage = () => {
|
||||
}, [redirectTimer]);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
return <Navigate to={`/login${compiledParams}`} replace />;
|
||||
}
|
||||
|
||||
if (oauth.active) {
|
||||
|
||||
@@ -16,10 +16,14 @@ 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";
|
||||
import {
|
||||
recompileScreenParams,
|
||||
useScreenParams,
|
||||
} from "@/lib/hooks/screen-params";
|
||||
import { useLoginFor } from "@/lib/hooks/login-for";
|
||||
|
||||
export const TotpPage = () => {
|
||||
const { totp } = useUserContext();
|
||||
const { totp, auth } = useUserContext();
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const formId = useId();
|
||||
@@ -27,8 +31,12 @@ export const TotpPage = () => {
|
||||
const redirectTimer = useRef<number | null>(null);
|
||||
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const redirectUri = searchParams.get("redirect_uri") || undefined;
|
||||
const oidcParams = useOIDCParams(searchParams);
|
||||
const screenParams = useScreenParams(searchParams);
|
||||
const compiledParams = recompileScreenParams(screenParams);
|
||||
const loginForUrl = useLoginFor({
|
||||
login_for: screenParams.login_for,
|
||||
compiledParams,
|
||||
});
|
||||
|
||||
const totpMutation = useMutation({
|
||||
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
|
||||
@@ -39,14 +47,7 @@ export const TotpPage = () => {
|
||||
});
|
||||
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
if (oidcParams.isOidc) {
|
||||
window.location.replace(`/authorize?${oidcParams.compiled}`);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.replace(
|
||||
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
|
||||
);
|
||||
window.location.replace(loginForUrl);
|
||||
}, 500);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -65,7 +66,10 @@ export const TotpPage = () => {
|
||||
}, [redirectTimer]);
|
||||
|
||||
if (!totp.pending) {
|
||||
return <Navigate to="/" replace />;
|
||||
if (auth.authenticated) {
|
||||
return <Navigate to={loginForUrl} replace />;
|
||||
}
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const getOidcClientInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
@@ -57,6 +57,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/robots.txt/, ""),
|
||||
},
|
||||
"/authorize": {
|
||||
target: "http://tinyauth-backend:3000/authorize",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/authorize/, ""),
|
||||
},
|
||||
},
|
||||
allowedHosts: true,
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
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/google/go-querystring v1.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
|
||||
@@ -216,6 +216,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
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/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/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
|
||||
@@ -59,7 +59,7 @@ func (app *BootstrapApp) setupRouter() error {
|
||||
|
||||
controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
|
||||
controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
|
||||
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter)
|
||||
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter, &engine.RouterGroup)
|
||||
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.NewResourcesController(app.config, &engine.RouterGroup)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package controller
|
||||
|
||||
type FrontendLoginFor string
|
||||
|
||||
const (
|
||||
FrontendLoginForOIDC FrontendLoginFor = "oidc"
|
||||
FrontendLoginForApp FrontendLoginFor = "app"
|
||||
)
|
||||
|
||||
type UnauthorizedQuery struct {
|
||||
Username string `url:"username"`
|
||||
Resource string `url:"resource"`
|
||||
@@ -8,5 +15,6 @@ type UnauthorizedQuery struct {
|
||||
}
|
||||
|
||||
type RedirectQuery struct {
|
||||
RedirectURI string `url:"redirect_uri"`
|
||||
RedirectURI string `url:"redirect_uri"`
|
||||
LoginFor FrontendLoginFor `url:"login_for"`
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var reqParams service.OAuthURLParams
|
||||
var reqParams service.OAuthCallbackParams
|
||||
|
||||
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 {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session")
|
||||
@@ -272,13 +272,14 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/authorize?%s", controller.runtime.AppURL, queries.Encode()))
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/oidc/authorize?%s", controller.runtime.AppURL, queries.Encode()))
|
||||
return
|
||||
}
|
||||
|
||||
if oauthPendingSession.CallbackParams.RedirectURI != "" {
|
||||
queries, err := query.Values(RedirectQuery{
|
||||
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
|
||||
LoginFor: FrontendLoginForApp,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -294,11 +295,8 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
c.Redirect(http.StatusTemporaryRedirect, controller.runtime.AppURL)
|
||||
}
|
||||
|
||||
func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams) bool {
|
||||
return params.Scope != "" &&
|
||||
params.ResponseType != "" &&
|
||||
params.ClientID != "" &&
|
||||
params.RedirectURI != ""
|
||||
func (controller *OAuthController) isOidcRequest(params service.OAuthCallbackParams) bool {
|
||||
return params.LoginFor == string(FrontendLoginForOIDC)
|
||||
}
|
||||
|
||||
func (controller *OAuthController) getCookieDomain() string {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/google/go-querystring/query"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
@@ -23,6 +24,7 @@ type authorizeErrorParams struct {
|
||||
callback string
|
||||
callbackError string
|
||||
state string
|
||||
json bool
|
||||
}
|
||||
|
||||
type OIDCController struct {
|
||||
@@ -65,20 +67,34 @@ type ClientCredentials struct {
|
||||
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(
|
||||
log *logger.Logger,
|
||||
oidcService *service.OIDCService,
|
||||
runtimeConfig model.RuntimeConfig,
|
||||
router *gin.RouterGroup) *OIDCController {
|
||||
router *gin.RouterGroup,
|
||||
mainRouter *gin.RouterGroup) *OIDCController {
|
||||
controller := &OIDCController{
|
||||
log: log,
|
||||
oidc: oidcService,
|
||||
runtime: runtimeConfig,
|
||||
}
|
||||
|
||||
mainRouter.POST("/authorize", controller.authorize)
|
||||
mainRouter.GET("/authorize", controller.authorize)
|
||||
|
||||
oidcGroup := router.Group("/oidc")
|
||||
oidcGroup.GET("/clients/:id", controller.GetClientInfo)
|
||||
oidcGroup.POST("/authorize", controller.Authorize)
|
||||
oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
|
||||
oidcGroup.POST("/token", controller.Token)
|
||||
oidcGroup.GET("/userinfo", controller.Userinfo)
|
||||
oidcGroup.POST("/userinfo", controller.Userinfo)
|
||||
@@ -86,47 +102,10 @@ func NewOIDCController(
|
||||
return controller
|
||||
}
|
||||
|
||||
func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||
if controller.oidc == nil {
|
||||
controller.log.App.Warn().Msg("Received OIDC client info request but OIDC server is not configured")
|
||||
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) {
|
||||
// This endpoint does **not** return a code, it handles param validation, ticket creation
|
||||
// and then redirects to the frontend to handle the consent screen. It performs no destructive
|
||||
// actions (like logging out an existing session)
|
||||
func (controller *OIDCController) authorize(c *gin.Context) {
|
||||
if controller.oidc == nil {
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: errors.New("err_oidc_not_configured"),
|
||||
@@ -136,40 +115,40 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
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",
|
||||
})
|
||||
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",
|
||||
})
|
||||
return
|
||||
}
|
||||
reqQueries := c.Request.URL.Query()
|
||||
|
||||
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
|
||||
// step 1: if we have a request object, decode it and ignore other params. If not, bind the params as usual
|
||||
if raw := reqQueries.Get("request"); raw != "" {
|
||||
requestObject, err := controller.oidc.DecodeAuthorizeJWT(raw)
|
||||
if err != nil {
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: err,
|
||||
reason: "Failed to decode request object",
|
||||
reasonPublic: "The client provided an invalid request object",
|
||||
})
|
||||
return
|
||||
}
|
||||
req = *requestObject
|
||||
} else {
|
||||
// step 2: by default we assume normal GET query parameters
|
||||
bind := binding.Query
|
||||
// step 3: if it's a POST request, we try form parameters
|
||||
if c.Request.Method == http.MethodPost {
|
||||
bind = binding.Form
|
||||
}
|
||||
if err := c.ShouldBindWith(&req, bind); err != nil {
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: err,
|
||||
reason: "Failed to bind request parameters",
|
||||
reasonPublic: "The client provided invalid parameters",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, ok := controller.oidc.GetClient(req.ClientID)
|
||||
client, ok := controller.oidc.GetClient(req.ClientID)
|
||||
|
||||
if !ok {
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
@@ -180,7 +159,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.oidc.ValidateAuthorizeParams(req)
|
||||
err := controller.oidc.ValidateAuthorizeParams(req)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params")
|
||||
@@ -203,8 +182,97 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
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
|
||||
sub := controller.oidc.CreateSub(*userContext, req.ClientID)
|
||||
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)
|
||||
|
||||
// Before storing the code, delete old session
|
||||
err = controller.oidc.DeleteOldSession(c, sub)
|
||||
@@ -213,19 +281,19 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
err: err,
|
||||
reason: "Failed to delete old sessions",
|
||||
reasonPublic: "Failed to delete old sessions",
|
||||
callback: req.RedirectURI,
|
||||
callback: authorizeReq.RedirectURI,
|
||||
callbackError: "server_error",
|
||||
state: req.State,
|
||||
state: authorizeReq.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create the authorization code
|
||||
code := controller.oidc.CreateCode(req, *userContext)
|
||||
code := controller.oidc.CreateCode(*authorizeReq, *userContext)
|
||||
|
||||
queries, err := query.Values(AuthorizeCallback{
|
||||
Code: code,
|
||||
State: req.State,
|
||||
State: authorizeReq.State,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -233,16 +301,16 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
err: err,
|
||||
reason: "Failed to build query",
|
||||
reasonPublic: "Failed to build query",
|
||||
callback: req.RedirectURI,
|
||||
callback: authorizeReq.RedirectURI,
|
||||
callbackError: "server_error",
|
||||
state: req.State,
|
||||
state: authorizeReq.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": fmt.Sprintf("%s?%s", req.RedirectURI, queries.Encode()),
|
||||
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -533,14 +601,22 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
|
||||
queries, err := query.Values(errorQueries)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to build callback error query")
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": fmt.Sprintf("%s?%s", params.callback, queries.Encode()),
|
||||
})
|
||||
redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode())
|
||||
|
||||
if params.json {
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": redirectUrl,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, redirectUrl)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -551,6 +627,7 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
|
||||
queries, err := query.Values(errorQueries)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to build error query")
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -563,8 +640,13 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
|
||||
redirectUrl = fmt.Sprintf("%s/error?%s", controller.runtime.AppURL, queries.Encode())
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": redirectUrl,
|
||||
})
|
||||
if params.json {
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": redirectUrl,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, redirectUrl)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -275,6 +275,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
|
||||
queries, err := query.Values(RedirectQuery{
|
||||
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
||||
LoginFor: FrontendLoginForApp,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package controller_test
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -76,7 +77,9 @@ func TestProxyController(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/"))
|
||||
assert.Contains(t, location, url.QueryEscape("app")) // login_for=app
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -89,7 +92,9 @@ func TestProxyController(t *testing.T) {
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, 401, recorder.Code)
|
||||
location := recorder.Header().Get("x-tinyauth-location")
|
||||
assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/"))
|
||||
assert.Contains(t, location, url.QueryEscape("app")) // login_for=app
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -103,7 +108,9 @@ func TestProxyController(t *testing.T) {
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2Fhello", location)
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/hello"))
|
||||
assert.Contains(t, location, url.QueryEscape("app")) // login_for=app
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -119,7 +126,9 @@ func TestProxyController(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/"))
|
||||
assert.Contains(t, location, url.QueryEscape("app")) // login_for=app
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -134,7 +143,9 @@ func TestProxyController(t *testing.T) {
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, 401, recorder.Code)
|
||||
location := recorder.Header().Get("x-tinyauth-location")
|
||||
assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/"))
|
||||
assert.Contains(t, location, url.QueryEscape("app")) // login_for=app
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -150,7 +161,9 @@ func TestProxyController(t *testing.T) {
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2Fhello", location)
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/"))
|
||||
assert.Contains(t, location, url.QueryEscape("app")) // login_for=app
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc {
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
|
||||
switch strings.SplitN(path, "/", 2)[0] {
|
||||
case "api", "resources", ".well-known":
|
||||
case "api", "resources", ".well-known", "authorize":
|
||||
c.Next()
|
||||
return
|
||||
case "robots.txt":
|
||||
|
||||
@@ -30,17 +30,14 @@ var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
)
|
||||
|
||||
// slightly modified version of the AuthorizeRequest from the OIDC service to basically accept all
|
||||
// parameters and pass them to the authorize page if needed
|
||||
type OAuthURLParams struct {
|
||||
Scope string `form:"scope" url:"scope"`
|
||||
ResponseType string `form:"response_type" url:"response_type"`
|
||||
ClientID string `form:"client_id" url:"client_id"`
|
||||
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"`
|
||||
// We either store params for redirecting to an app after OAuth login,
|
||||
// or for redirecting back to the authorize screen to continue OIDC
|
||||
type OAuthCallbackParams struct {
|
||||
LoginFor string `form:"login_for" url:"login_for"`
|
||||
OIDCTicket string `form:"oidc_ticket" url:"oidc_ticket"`
|
||||
OIDCScope string `form:"oidc_scope" url:"oidc_scope"`
|
||||
OIDCName string `form:"oidc_name" url:"oidc_name"`
|
||||
RedirectURI string `form:"redirect_uri" url:"redirect_uri"`
|
||||
}
|
||||
|
||||
type OAuthPendingSession struct {
|
||||
@@ -49,7 +46,7 @@ type OAuthPendingSession struct {
|
||||
Token *oauth2.Token
|
||||
Service *OAuthServiceImpl
|
||||
ExpiresAt time.Time
|
||||
CallbackParams OAuthURLParams
|
||||
CallbackParams OAuthCallbackParams
|
||||
}
|
||||
|
||||
type LoginAttempt struct {
|
||||
@@ -516,17 +513,17 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
|
||||
return auth.ldap != nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
|
||||
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthCallbackParams) (string, error) {
|
||||
service, ok := auth.oauthBroker.GetService(serviceName)
|
||||
|
||||
if !ok {
|
||||
return "", OAuthPendingSession{}, fmt.Errorf("oauth service not found: %s", serviceName)
|
||||
return "", fmt.Errorf("oauth service not found: %s", serviceName)
|
||||
}
|
||||
|
||||
sessionId, err := uuid.NewRandom()
|
||||
|
||||
if err != nil {
|
||||
return "", OAuthPendingSession{}, fmt.Errorf("failed to generate session ID: %w", err)
|
||||
return "", fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
|
||||
state := service.NewRandom()
|
||||
@@ -542,7 +539,7 @@ func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLPara
|
||||
|
||||
auth.caches.oauth.Set(sessionId.String(), session, time.Minute*10)
|
||||
|
||||
return sessionId.String(), session, nil
|
||||
return sessionId.String(), nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
@@ -106,14 +107,15 @@ type TokenResponse struct {
|
||||
}
|
||||
|
||||
type AuthorizeRequest struct {
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
ResponseType string `json:"response_type" binding:"required"`
|
||||
ClientID string `json:"client_id" binding:"required"`
|
||||
RedirectURI string `json:"redirect_uri" binding:"required"`
|
||||
State string `json:"state"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||
jwt.Claims
|
||||
Scope string `form:"scope" json:"scope" url:"scope"`
|
||||
ResponseType string `form:"response_type" json:"response_type" url:"response_type"`
|
||||
ClientID string `form:"client_id" json:"client_id" url:"client_id"`
|
||||
RedirectURI string `form:"redirect_uri" json:"redirect_uri" url:"redirect_uri"`
|
||||
State string `form:"state" json:"state" url:"state"`
|
||||
Nonce string `form:"nonce" json:"nonce" url:"nonce"`
|
||||
CodeChallenge string `form:"code_challenge" json:"code_challenge" url:"code_challenge"`
|
||||
CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" url:"code_challenge_method"`
|
||||
}
|
||||
|
||||
type AuthorizeCodeEntry struct {
|
||||
@@ -142,8 +144,9 @@ type OIDCService struct {
|
||||
issuer string
|
||||
|
||||
caches struct {
|
||||
code *CacheStore[AuthorizeCodeEntry]
|
||||
usedCode *CacheStore[UsedCodeEntry]
|
||||
code *CacheStore[AuthorizeCodeEntry]
|
||||
usedCode *CacheStore[UsedCodeEntry]
|
||||
authorize *CacheStore[AuthorizeRequest]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,8 +314,11 @@ func NewOIDCService(
|
||||
// Create caches
|
||||
codeCash := NewCacheStore[AuthorizeCodeEntry](256)
|
||||
usedCode := NewCacheStore[UsedCodeEntry](256)
|
||||
authorize := NewCacheStore[AuthorizeRequest](256)
|
||||
|
||||
service.caches.code = codeCash
|
||||
service.caches.usedCode = usedCode
|
||||
service.caches.authorize = authorize
|
||||
|
||||
// Start cache cleanup routine
|
||||
dg.Go(func(ctx context.Context) {
|
||||
@@ -324,6 +330,7 @@ func NewOIDCService(
|
||||
case <-ticker.C:
|
||||
service.caches.code.Sweep()
|
||||
service.caches.usedCode.Sweep()
|
||||
service.caches.authorize.Sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -856,3 +863,44 @@ func (service *OIDCService) MarkCodeAsUsed(codeHash string, sub string) {
|
||||
func (service *OIDCService) DeleteSessionBySub(ctx context.Context, sub string) error {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user