From 773942dc3b7bbda21adba08b904870d9690f29a2 Mon Sep 17 00:00:00 2001 From: Stavros Date: Thu, 1 May 2025 14:18:26 +0300 Subject: [PATCH] feat: add support for auto redirecting to oauth providers --- .env.example | 3 +- cmd/root.go | 3 ++ frontend/src/lib/hooks/use-is-mounted.ts | 26 ++++++++++ frontend/src/pages/continue-page.tsx | 38 +++++++++------ frontend/src/pages/login-page.tsx | 55 +++++++++++++++++++--- frontend/src/pages/unauthorized-page.tsx | 8 ++-- frontend/src/schemas/app-context-schema.ts | 1 + frontend/src/utils/utils.ts | 18 ++++++- internal/handlers/handlers.go | 1 + internal/types/api.go | 1 + internal/types/config.go | 2 + 11 files changed, 129 insertions(+), 27 deletions(-) create mode 100644 frontend/src/lib/hooks/use-is-mounted.ts diff --git a/.env.example b/.env.example index e131e6b..fc33be8 100644 --- a/.env.example +++ b/.env.example @@ -27,4 +27,5 @@ LOGIN_TIMEOUT=300 LOGIN_MAX_RETRIES=5 LOG_LEVEL=0 APP_TITLE=Tinyauth SSO -FORGOT_PASSWORD_MESSAGE=Some message about resetting the password \ No newline at end of file +FORGOT_PASSWORD_MESSAGE=Some message about resetting the password +OAUTH_AUTO_REDIRECT=none \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index d416702..eabbfb6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -91,6 +91,7 @@ var rootCmd = &cobra.Command{ CookieSecure: config.CookieSecure, Domain: domain, ForgotPasswordMessage: config.FogotPasswordMessage, + OAuthAutoRedirect: config.OAuthAutoRedirect, } // Create api config @@ -197,6 +198,7 @@ func init() { rootCmd.Flags().String("generic-name", "Generic", "Generic OAuth provider name.") rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.") rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.") + rootCmd.Flags().String("oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)") rootCmd.Flags().Int("session-expiry", 86400, "Session (cookie) expiration time in seconds.") rootCmd.Flags().Int("login-timeout", 300, "Login timeout in seconds after max retries reached (0 to disable).") rootCmd.Flags().Int("login-max-retries", 5, "Maximum login attempts before timeout (0 to disable).") @@ -229,6 +231,7 @@ func init() { viper.BindEnv("generic-name", "GENERIC_NAME") viper.BindEnv("disable-continue", "DISABLE_CONTINUE") viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST") + viper.BindEnv("oauth-auto-redirect", "OAUTH_AUTO_REDIRECT") viper.BindEnv("session-expiry", "SESSION_EXPIRY") viper.BindEnv("log-level", "LOG_LEVEL") viper.BindEnv("app-title", "APP_TITLE") diff --git a/frontend/src/lib/hooks/use-is-mounted.ts b/frontend/src/lib/hooks/use-is-mounted.ts new file mode 100644 index 0000000..1fabe17 --- /dev/null +++ b/frontend/src/lib/hooks/use-is-mounted.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useRef } from 'react' + +/** + * Custom hook that determines if the component is currently mounted. + * @returns {() => boolean} A function that returns a boolean value indicating whether the component is mounted. + * @public + * @see [Documentation](https://usehooks-ts.com/react-hook/use-is-mounted) + * @example + * ```tsx + * const isComponentMounted = useIsMounted(); + * // Use isComponentMounted() to check if the component is currently mounted before performing certain actions. + * ``` + */ +export function useIsMounted(): () => boolean { + const isMounted = useRef(false) + + useEffect(() => { + isMounted.current = true + + return () => { + isMounted.current = false + } + }, []) + + return useCallback(() => isMounted.current, []) +} \ No newline at end of file diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index 8061282..846b7d0 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -4,7 +4,7 @@ import { Navigate } from "react-router"; import { useUserContext } from "../context/user-context"; import { Layout } from "../components/layouts/layout"; import { ReactNode } from "react"; -import { escapeRegex, isQueryValid } from "../utils/utils"; +import { escapeRegex, isValidRedirectUri } from "../utils/utils"; import { useAppContext } from "../context/app-context"; import { Trans, useTranslation } from "react-i18next"; @@ -21,7 +21,7 @@ export const ContinuePage = () => { return ; } - if (!isQueryValid(redirectUri)) { + if (!isValidRedirectUri(redirectUri)) { return ; } @@ -51,7 +51,7 @@ export const ContinuePage = () => { ); } - const regex = new RegExp(`^.*${escapeRegex(domain)}$`) + const regex = new RegExp(`^.*${escapeRegex(domain)}$`); if (!regex.test(uri.hostname)) { return ( @@ -60,19 +60,24 @@ export const ContinuePage = () => { {t("untrustedRedirectTitle")} }} - values={{ domain: domain }} - /> + i18nKey="untrustedRedirectSubtitle" + t={t} + components={{ Code: }} + values={{ domain: domain }} + /> - - ) + ); } if (disableContinue) { @@ -103,8 +108,13 @@ export const ContinuePage = () => { - ); diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index d9a06e4..fbffa48 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -8,9 +8,11 @@ import { Layout } from "../components/layouts/layout"; import { OAuthButtons } from "../components/auth/oauth-buttons"; import { LoginFormValues } from "../schemas/login-schema"; import { LoginForm } from "../components/auth/login-forn"; -import { isQueryValid } from "../utils/utils"; import { useAppContext } from "../context/app-context"; import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import { useIsMounted } from "../lib/hooks/use-is-mounted"; +import { isValidRedirectUri } from "../utils/utils"; export const LoginPage = () => { const queryString = window.location.search; @@ -18,16 +20,29 @@ export const LoginPage = () => { const redirectUri = params.get("redirect_uri") ?? ""; const { isLoggedIn } = useUserContext(); - const { configuredProviders, title, genericName } = useAppContext(); + + if (isLoggedIn) { + return ; + } + + const { + configuredProviders, + title, + genericName, + oauthAutoRedirect: oauthAutoRedirectContext, + } = useAppContext(); + const { t } = useTranslation(); + const [oauthAutoRedirect, setOAuthAutoRedirect] = useState( + oauthAutoRedirectContext, + ); + const oauthProviders = configuredProviders.filter( (value) => value !== "username", ); - if (isLoggedIn) { - return ; - } + const isMounted = useIsMounted(); const loginMutation = useMutation({ mutationFn: (login: LoginFormValues) => { @@ -63,7 +78,7 @@ export const LoginPage = () => { }); setTimeout(() => { - if (!isQueryValid(redirectUri)) { + if (!isValidRedirectUri(redirectUri)) { window.location.replace("/"); return; } @@ -85,6 +100,7 @@ export const LoginPage = () => { message: t("loginOauthFailSubtitle"), color: "red", }); + setOAuthAutoRedirect("none"); }, onSuccess: (data) => { notifications.show({ @@ -102,6 +118,33 @@ export const LoginPage = () => { loginMutation.mutate(values); }; + useEffect(() => { + if (isMounted()) { + if ( + oauthProviders.includes(oauthAutoRedirect) && + isValidRedirectUri(redirectUri) + ) { + loginOAuthMutation.mutate(oauthAutoRedirect); + } + } + }, []); + + if ( + oauthProviders.includes(oauthAutoRedirect) && + isValidRedirectUri(redirectUri) + ) { + return ( + + + + {t("continueRedirectingTitle")} + + {t("loginOauthSuccessSubtitle")} + + + ); + } + return ( {title} diff --git a/frontend/src/pages/unauthorized-page.tsx b/frontend/src/pages/unauthorized-page.tsx index 84bb8d6..1646b3e 100644 --- a/frontend/src/pages/unauthorized-page.tsx +++ b/frontend/src/pages/unauthorized-page.tsx @@ -1,9 +1,9 @@ import { Button, Code, Paper, Text } from "@mantine/core"; import { Layout } from "../components/layouts/layout"; import { Navigate } from "react-router"; -import { isQueryValid } from "../utils/utils"; import { Trans, useTranslation } from "react-i18next"; import React from "react"; +import { isValidQuery } from "../utils/utils"; export const UnauthorizedPage = () => { const queryString = window.location.search; @@ -14,11 +14,11 @@ export const UnauthorizedPage = () => { const { t } = useTranslation(); - if (!isQueryValid(username)) { + if (!isValidQuery(username)) { return ; } - if (isQueryValid(resource) && !isQueryValid(groupErr)) { + if (isValidQuery(resource) && !isValidQuery(groupErr)) { return ( { ); } - if (isQueryValid(groupErr) && isQueryValid(resource)) { + if (isValidQuery(groupErr) && isValidQuery(resource)) { return ( ; diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index bef72fc..fe98892 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -1,3 +1,17 @@ export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); -export const isQueryValid = (value: string) => value.trim() !== "" && value !== "null"; -export const escapeRegex = (value: string) => value.replace(/[-\/\\^$.*+?()[\]{}|]/g, "\\$&"); \ No newline at end of file +export const escapeRegex = (value: string) => value.replace(/[-\/\\^$.*+?()[\]{}|]/g, "\\$&"); +export const isValidQuery = (query: string) => query && query.trim() !== ""; + +export const isValidRedirectUri = (value: string) => { + if (!isValidQuery(value)) { + return false; + } + + try { + new URL(value); + } catch { + return false; + } + + return true; +} \ No newline at end of file diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 881b5d6..89aa512 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -506,6 +506,7 @@ func (h *Handlers) AppHandler(c *gin.Context) { GenericName: h.Config.GenericName, Domain: h.Config.Domain, ForgotPasswordMessage: h.Config.ForgotPasswordMessage, + OAuthAutoRedirect: h.Config.OAuthAutoRedirect, } // Return app context diff --git a/internal/types/api.go b/internal/types/api.go index 6347884..028ae1f 100644 --- a/internal/types/api.go +++ b/internal/types/api.go @@ -51,6 +51,7 @@ type AppContext struct { GenericName string `json:"genericName"` Domain string `json:"domain"` ForgotPasswordMessage string `json:"forgotPasswordMessage"` + OAuthAutoRedirect string `json:"oauthAutoRedirect"` } // Totp request is the request for the totp endpoint diff --git a/internal/types/config.go b/internal/types/config.go index cc35de1..72fd300 100644 --- a/internal/types/config.go +++ b/internal/types/config.go @@ -26,6 +26,7 @@ type Config struct { GenericName string `mapstructure:"generic-name"` DisableContinue bool `mapstructure:"disable-continue"` OAuthWhitelist string `mapstructure:"oauth-whitelist"` + OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"` SessionExpiry int `mapstructure:"session-expiry"` LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"` Title string `mapstructure:"app-title"` @@ -44,6 +45,7 @@ type HandlersConfig struct { GenericName string Title string ForgotPasswordMessage string + OAuthAutoRedirect string } // OAuthConfig is the configuration for the providers