feat: add support for auto redirecting to oauth providers

This commit is contained in:
Stavros
2025-05-01 14:18:26 +03:00
parent 83483d6374
commit 773942dc3b
11 changed files with 129 additions and 27 deletions

View File

@@ -28,3 +28,4 @@ LOGIN_MAX_RETRIES=5
LOG_LEVEL=0
APP_TITLE=Tinyauth SSO
FORGOT_PASSWORD_MESSAGE=Some message about resetting the password
OAUTH_AUTO_REDIRECT=none

View File

@@ -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")

View File

@@ -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, [])
}

View File

@@ -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 <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
}
if (!isQueryValid(redirectUri)) {
if (!isValidRedirectUri(redirectUri)) {
return <Navigate to="/" />;
}
@@ -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 (
@@ -66,13 +66,18 @@ export const ContinuePage = () => {
values={{ domain: domain }}
/>
<Button fullWidth mt="xl" color="red" onClick={redirect}>
{t('continueTitle')}
{t("continueTitle")}
</Button>
<Button fullWidth mt="sm" color="gray" onClick={() => window.location.href = "/"}>
{t('cancelTitle')}
<Button
fullWidth
mt="sm"
color="gray"
onClick={() => (window.location.href = "/")}
>
{t("cancelTitle")}
</Button>
</ContinuePageLayout>
)
);
}
if (disableContinue) {
@@ -103,8 +108,13 @@ export const ContinuePage = () => {
<Button fullWidth mt="xl" color="yellow" onClick={redirect}>
{t("continueTitle")}
</Button>
<Button fullWidth mt="sm" color="gray" onClick={() => window.location.href = "/"}>
{t('cancelTitle')}
<Button
fullWidth
mt="sm"
color="gray"
onClick={() => (window.location.href = "/")}
>
{t("cancelTitle")}
</Button>
</ContinuePageLayout>
);

View File

@@ -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 <Navigate to="/logout" />;
}
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 <Navigate to="/logout" />;
}
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 (
<Layout>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
<Text size="xl" fw={700}>
{t("continueRedirectingTitle")}
</Text>
<Text>{t("loginOauthSuccessSubtitle")}</Text>
</Paper>
</Layout>
);
}
return (
<Layout>
<Title ta="center">{title}</Title>

View File

@@ -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 <Navigate to="/" />;
}
if (isQueryValid(resource) && !isQueryValid(groupErr)) {
if (isValidQuery(resource) && !isValidQuery(groupErr)) {
return (
<UnauthorizedLayout>
<Trans
@@ -31,7 +31,7 @@ export const UnauthorizedPage = () => {
);
}
if (isQueryValid(groupErr) && isQueryValid(resource)) {
if (isValidQuery(groupErr) && isValidQuery(resource)) {
return (
<UnauthorizedLayout>
<Trans

View File

@@ -7,6 +7,7 @@ export const appContextSchema = z.object({
genericName: z.string(),
domain: z.string(),
forgotPasswordMessage: z.string(),
oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]),
});
export type AppContextSchemaType = z.infer<typeof appContextSchema>;

View File

@@ -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, "\\$&");
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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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