mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-28 04:35:40 +00:00
feat: add support for auto redirecting to oauth providers
This commit is contained in:
@@ -28,3 +28,4 @@ LOGIN_MAX_RETRIES=5
|
|||||||
LOG_LEVEL=0
|
LOG_LEVEL=0
|
||||||
APP_TITLE=Tinyauth SSO
|
APP_TITLE=Tinyauth SSO
|
||||||
FORGOT_PASSWORD_MESSAGE=Some message about resetting the password
|
FORGOT_PASSWORD_MESSAGE=Some message about resetting the password
|
||||||
|
OAUTH_AUTO_REDIRECT=none
|
||||||
@@ -91,6 +91,7 @@ var rootCmd = &cobra.Command{
|
|||||||
CookieSecure: config.CookieSecure,
|
CookieSecure: config.CookieSecure,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
ForgotPasswordMessage: config.FogotPasswordMessage,
|
ForgotPasswordMessage: config.FogotPasswordMessage,
|
||||||
|
OAuthAutoRedirect: config.OAuthAutoRedirect,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create api config
|
// Create api config
|
||||||
@@ -197,6 +198,7 @@ func init() {
|
|||||||
rootCmd.Flags().String("generic-name", "Generic", "Generic OAuth provider name.")
|
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().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-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("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-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).")
|
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("generic-name", "GENERIC_NAME")
|
||||||
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
|
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
|
||||||
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
|
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
|
||||||
|
viper.BindEnv("oauth-auto-redirect", "OAUTH_AUTO_REDIRECT")
|
||||||
viper.BindEnv("session-expiry", "SESSION_EXPIRY")
|
viper.BindEnv("session-expiry", "SESSION_EXPIRY")
|
||||||
viper.BindEnv("log-level", "LOG_LEVEL")
|
viper.BindEnv("log-level", "LOG_LEVEL")
|
||||||
viper.BindEnv("app-title", "APP_TITLE")
|
viper.BindEnv("app-title", "APP_TITLE")
|
||||||
|
|||||||
26
frontend/src/lib/hooks/use-is-mounted.ts
Normal file
26
frontend/src/lib/hooks/use-is-mounted.ts
Normal 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, [])
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Navigate } from "react-router";
|
|||||||
import { useUserContext } from "../context/user-context";
|
import { useUserContext } from "../context/user-context";
|
||||||
import { Layout } from "../components/layouts/layout";
|
import { Layout } from "../components/layouts/layout";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { escapeRegex, isQueryValid } from "../utils/utils";
|
import { escapeRegex, isValidRedirectUri } from "../utils/utils";
|
||||||
import { useAppContext } from "../context/app-context";
|
import { useAppContext } from "../context/app-context";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export const ContinuePage = () => {
|
|||||||
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
|
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isQueryValid(redirectUri)) {
|
if (!isValidRedirectUri(redirectUri)) {
|
||||||
return <Navigate to="/" />;
|
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)) {
|
if (!regex.test(uri.hostname)) {
|
||||||
return (
|
return (
|
||||||
@@ -60,19 +60,24 @@ export const ContinuePage = () => {
|
|||||||
{t("untrustedRedirectTitle")}
|
{t("untrustedRedirectTitle")}
|
||||||
</Text>
|
</Text>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="untrustedRedirectSubtitle"
|
i18nKey="untrustedRedirectSubtitle"
|
||||||
t={t}
|
t={t}
|
||||||
components={{ Code: <Code /> }}
|
components={{ Code: <Code /> }}
|
||||||
values={{ domain: domain }}
|
values={{ domain: domain }}
|
||||||
/>
|
/>
|
||||||
<Button fullWidth mt="xl" color="red" onClick={redirect}>
|
<Button fullWidth mt="xl" color="red" onClick={redirect}>
|
||||||
{t('continueTitle')}
|
{t("continueTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button fullWidth mt="sm" color="gray" onClick={() => window.location.href = "/"}>
|
<Button
|
||||||
{t('cancelTitle')}
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => (window.location.href = "/")}
|
||||||
|
>
|
||||||
|
{t("cancelTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</ContinuePageLayout>
|
</ContinuePageLayout>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disableContinue) {
|
if (disableContinue) {
|
||||||
@@ -103,8 +108,13 @@ export const ContinuePage = () => {
|
|||||||
<Button fullWidth mt="xl" color="yellow" onClick={redirect}>
|
<Button fullWidth mt="xl" color="yellow" onClick={redirect}>
|
||||||
{t("continueTitle")}
|
{t("continueTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button fullWidth mt="sm" color="gray" onClick={() => window.location.href = "/"}>
|
<Button
|
||||||
{t('cancelTitle')}
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => (window.location.href = "/")}
|
||||||
|
>
|
||||||
|
{t("cancelTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</ContinuePageLayout>
|
</ContinuePageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { Layout } from "../components/layouts/layout";
|
|||||||
import { OAuthButtons } from "../components/auth/oauth-buttons";
|
import { OAuthButtons } from "../components/auth/oauth-buttons";
|
||||||
import { LoginFormValues } from "../schemas/login-schema";
|
import { LoginFormValues } from "../schemas/login-schema";
|
||||||
import { LoginForm } from "../components/auth/login-forn";
|
import { LoginForm } from "../components/auth/login-forn";
|
||||||
import { isQueryValid } from "../utils/utils";
|
|
||||||
import { useAppContext } from "../context/app-context";
|
import { useAppContext } from "../context/app-context";
|
||||||
import { useTranslation } from "react-i18next";
|
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 = () => {
|
export const LoginPage = () => {
|
||||||
const queryString = window.location.search;
|
const queryString = window.location.search;
|
||||||
@@ -18,16 +20,29 @@ export const LoginPage = () => {
|
|||||||
const redirectUri = params.get("redirect_uri") ?? "";
|
const redirectUri = params.get("redirect_uri") ?? "";
|
||||||
|
|
||||||
const { isLoggedIn } = useUserContext();
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
|
const [oauthAutoRedirect, setOAuthAutoRedirect] = useState(
|
||||||
|
oauthAutoRedirectContext,
|
||||||
|
);
|
||||||
|
|
||||||
const oauthProviders = configuredProviders.filter(
|
const oauthProviders = configuredProviders.filter(
|
||||||
(value) => value !== "username",
|
(value) => value !== "username",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoggedIn) {
|
const isMounted = useIsMounted();
|
||||||
return <Navigate to="/logout" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: (login: LoginFormValues) => {
|
mutationFn: (login: LoginFormValues) => {
|
||||||
@@ -63,7 +78,7 @@ export const LoginPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!isQueryValid(redirectUri)) {
|
if (!isValidRedirectUri(redirectUri)) {
|
||||||
window.location.replace("/");
|
window.location.replace("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -85,6 +100,7 @@ export const LoginPage = () => {
|
|||||||
message: t("loginOauthFailSubtitle"),
|
message: t("loginOauthFailSubtitle"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
|
setOAuthAutoRedirect("none");
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -102,6 +118,33 @@ export const LoginPage = () => {
|
|||||||
loginMutation.mutate(values);
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Title ta="center">{title}</Title>
|
<Title ta="center">{title}</Title>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Button, Code, Paper, Text } from "@mantine/core";
|
import { Button, Code, Paper, Text } from "@mantine/core";
|
||||||
import { Layout } from "../components/layouts/layout";
|
import { Layout } from "../components/layouts/layout";
|
||||||
import { Navigate } from "react-router";
|
import { Navigate } from "react-router";
|
||||||
import { isQueryValid } from "../utils/utils";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { isValidQuery } from "../utils/utils";
|
||||||
|
|
||||||
export const UnauthorizedPage = () => {
|
export const UnauthorizedPage = () => {
|
||||||
const queryString = window.location.search;
|
const queryString = window.location.search;
|
||||||
@@ -14,11 +14,11 @@ export const UnauthorizedPage = () => {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!isQueryValid(username)) {
|
if (!isValidQuery(username)) {
|
||||||
return <Navigate to="/" />;
|
return <Navigate to="/" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQueryValid(resource) && !isQueryValid(groupErr)) {
|
if (isValidQuery(resource) && !isValidQuery(groupErr)) {
|
||||||
return (
|
return (
|
||||||
<UnauthorizedLayout>
|
<UnauthorizedLayout>
|
||||||
<Trans
|
<Trans
|
||||||
@@ -31,7 +31,7 @@ export const UnauthorizedPage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQueryValid(groupErr) && isQueryValid(resource)) {
|
if (isValidQuery(groupErr) && isValidQuery(resource)) {
|
||||||
return (
|
return (
|
||||||
<UnauthorizedLayout>
|
<UnauthorizedLayout>
|
||||||
<Trans
|
<Trans
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const appContextSchema = z.object({
|
|||||||
genericName: z.string(),
|
genericName: z.string(),
|
||||||
domain: z.string(),
|
domain: z.string(),
|
||||||
forgotPasswordMessage: z.string(),
|
forgotPasswordMessage: z.string(),
|
||||||
|
oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppContextSchemaType = z.infer<typeof appContextSchema>;
|
export type AppContextSchemaType = z.infer<typeof appContextSchema>;
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
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 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;
|
||||||
|
}
|
||||||
@@ -506,6 +506,7 @@ func (h *Handlers) AppHandler(c *gin.Context) {
|
|||||||
GenericName: h.Config.GenericName,
|
GenericName: h.Config.GenericName,
|
||||||
Domain: h.Config.Domain,
|
Domain: h.Config.Domain,
|
||||||
ForgotPasswordMessage: h.Config.ForgotPasswordMessage,
|
ForgotPasswordMessage: h.Config.ForgotPasswordMessage,
|
||||||
|
OAuthAutoRedirect: h.Config.OAuthAutoRedirect,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return app context
|
// Return app context
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type AppContext struct {
|
|||||||
GenericName string `json:"genericName"`
|
GenericName string `json:"genericName"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
||||||
|
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Totp request is the request for the totp endpoint
|
// Totp request is the request for the totp endpoint
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Config struct {
|
|||||||
GenericName string `mapstructure:"generic-name"`
|
GenericName string `mapstructure:"generic-name"`
|
||||||
DisableContinue bool `mapstructure:"disable-continue"`
|
DisableContinue bool `mapstructure:"disable-continue"`
|
||||||
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
||||||
|
OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"`
|
||||||
SessionExpiry int `mapstructure:"session-expiry"`
|
SessionExpiry int `mapstructure:"session-expiry"`
|
||||||
LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"`
|
LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"`
|
||||||
Title string `mapstructure:"app-title"`
|
Title string `mapstructure:"app-title"`
|
||||||
@@ -44,6 +45,7 @@ type HandlersConfig struct {
|
|||||||
GenericName string
|
GenericName string
|
||||||
Title string
|
Title string
|
||||||
ForgotPasswordMessage string
|
ForgotPasswordMessage string
|
||||||
|
OAuthAutoRedirect string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthConfig is the configuration for the providers
|
// OAuthConfig is the configuration for the providers
|
||||||
|
|||||||
Reference in New Issue
Block a user