fix: improve error handling, process callback on login page

This commit is contained in:
Michael Thomas
2025-04-07 10:54:26 -04:00
parent 7bd26eebb5
commit 55870fed20
11 changed files with 173 additions and 148 deletions

View File

@@ -4113,17 +4113,21 @@ paths:
type: string
example: 'authentik'
responses:
'302':
description: Redirect to the authentication url for the OpenID Connect provider
'200':
description: Authentication redirect url for the OpenID Connect provider
headers:
Location:
schema:
type: string
example: https://example.com/auth/oidc/callback?response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Foidc%2Fcallback&scope=openid%20email&state=state
Set-Cookie:
schema:
type: string
example: 'oidc-state=123456789; HttpOnly; max-age=60000; Secure'
content:
application/json:
schema:
type: object
properties:
redirectUrl:
type: string
example: https://example.com/auth/oidc/callback?response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Foidc%2Fcallback&scope=openid%20email&state=state
/auth/oidc/callback/{slug}:
get:
security: []

View File

@@ -754,8 +754,8 @@ authRoutes.get('/oidc/login/:slug', async (req, res, next) => {
try {
redirectUrl = await getOpenIdRedirectUrl(req, provider, state);
} catch (err) {
logger.info('Failed OIDC login attempt', {
cause: 'Failed to fetch OIDC redirect url',
logger.info('Failed OpenID Connect login attempt', {
cause: 'Failed to fetch OpenID Connect redirect url',
ip: req.ip,
errorMessage: err.message,
});
@@ -771,7 +771,9 @@ authRoutes.get('/oidc/login/:slug', async (req, res, next) => {
secure: req.protocol === 'https',
});
return res.redirect(redirectUrl);
return res.status(200).json({
redirectUrl,
});
});
authRoutes.get('/oidc/callback/:slug', async (req, res, next) => {
@@ -992,7 +994,7 @@ authRoutes.get('/oidc/callback/:slug', async (req, res, next) => {
logger.error('Failed OIDC login attempt', {
cause: 'Unknown error',
ip: req.ip,
errorMessage: error.message,
error,
});
return next({
status: 500,
@@ -1001,23 +1003,6 @@ authRoutes.get('/oidc/callback/:slug', async (req, res, next) => {
}
});
// authRoutes.get('/oidc-logout', async (req, res, next) => {
// const settings = getSettings();
// if (!settings.main.oidcLogin || !settings.main.oidc.automaticLogin) {
// return next({
// status: 403,
// message: 'OpenID Connect sign-in is disabled.',
// });
// }
// const oidcEndpoints = await getOIDCWellknownConfiguration(
// settings.main.oidc.providerUrl
// );
// return res.redirect(oidcEndpoints.end_session_endpoint);
// });
authRoutes.post('/logout', async (req, res, next) => {
try {
const userId = req.session?.userId;

View File

@@ -24,6 +24,16 @@ export async function getOpenIdConfiguration(domain: string) {
return wellKnownInfo;
}
function getOpenIdCallbackUrl(req: Request, provider: OidcProvider) {
const callbackUrl = new URL(
`/login`,
`${req.protocol}://${req.headers.host}`
);
callbackUrl.searchParams.set('provider', provider.slug);
callbackUrl.searchParams.set('callback', 'true');
return callbackUrl.toString();
}
/** Generate authentication request url */
export async function getOpenIdRedirectUrl(
req: Request,
@@ -35,12 +45,8 @@ export async function getOpenIdRedirectUrl(
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', provider.clientId);
const callbackUrl = new URL(
`/login/oidc/callback/${provider.slug}`,
`${req.protocol}://${req.headers.host}`
).toString();
url.searchParams.set('redirect_uri', callbackUrl);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('redirect_uri', getOpenIdCallbackUrl(req, provider));
url.searchParams.set('scope', provider.scopes ?? 'openid profile email');
url.searchParams.set('state', state);
return url.toString();
}
@@ -52,15 +58,10 @@ export async function fetchOpenIdTokenData(
wellKnownInfo: OidcProviderMetadata,
code: string
): Promise<OidcTokenResponse> {
const callbackUrl = new URL(
`/login/oidc/callback/${provider.slug}`,
`${req.protocol}://${req.headers.host}`
);
const formData = new URLSearchParams();
formData.append('client_secret', provider.clientSecret);
formData.append('grant_type', 'authorization_code');
formData.append('redirect_uri', callbackUrl.toString());
formData.append('redirect_uri', getOpenIdCallbackUrl(req, provider));
formData.append('client_id', provider.clientId);
formData.append('code', code);

View File

@@ -31,7 +31,7 @@ type BaseProps<P> = {
) => void;
};
type ButtonProps<P extends React.ElementType> = {
export type ButtonProps<P extends React.ElementType> = {
as?: P;
} & MergeElementProps<P, BaseProps<P>>;

View File

@@ -0,0 +1,35 @@
import Button, { type ButtonProps } from '@app/components/Common/Button';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import { type PropsWithChildren } from 'react';
import { twMerge } from 'tailwind-merge';
export type LoginButtonProps = ButtonProps<'button'> &
PropsWithChildren<{
loading?: boolean;
}>;
export default function LoginButton({
loading,
className,
children,
...buttonProps
}: LoginButtonProps) {
return (
<Button
className={twMerge(
'relative flex-grow bg-transparent disabled:opacity-50',
className
)}
disabled={loading}
{...buttonProps}
>
{loading && (
<div className="absolute right-0 mr-4 h-4 w-4">
<SmallLoadingSpinner />
</div>
)}
{children}
</Button>
);
}

View File

@@ -0,0 +1,90 @@
import defineMessages from '@app/utils/defineMessages';
import { processCallback } from '@app/utils/oidc';
import type { PublicOidcProvider } from '@server/lib/settings';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import LoginButton from './LoginButton';
const messages = defineMessages('components.Login', {
oidcLoginError: 'An error occurred while logging in with {provider}.',
});
type OidcLoginButtonProps = {
provider: PublicOidcProvider;
onError?: (message: string) => void;
};
export default function OidcLoginButton({
provider,
onError,
}: OidcLoginButtonProps) {
const intl = useIntl();
const searchParams = useSearchParams();
const router = useRouter();
const [loading, setLoading] = useState(false);
const redirectToLogin = useCallback(async () => {
let redirectUrl: string;
try {
const res = await fetch(`/api/v1/auth/oidc/login/${provider.slug}`);
if (res.ok) {
const data = await res.json();
redirectUrl = data.redirectUrl;
} else {
throw new Error();
}
} catch (e) {
setLoading(false);
onError?.(
intl.formatMessage(messages.oidcLoginError, {
provider: provider.name,
})
);
return;
}
window.location.href = redirectUrl;
}, [provider, intl, onError]);
const handleCallback = useCallback(async () => {
const result = await processCallback(searchParams, provider.slug);
if (result.type === 'success') {
// redirect to homepage
router.push(result.message?.to ?? '/');
} else {
setLoading(false);
onError?.(
intl.formatMessage(messages.oidcLoginError, {
provider: provider.name,
})
);
}
}, [provider, searchParams, intl, onError, router]);
useEffect(() => {
if (loading) return;
const isCallback = searchParams.get('callback') === 'true';
const providerSlug = searchParams.get('provider');
if (providerSlug === provider.slug) {
setLoading(true);
if (isCallback) handleCallback();
else redirectToLogin();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<LoginButton loading={loading} onClick={() => redirectToLogin()}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={provider.logo || '/images/openid.svg'}
alt={provider.name}
className="mr-2 max-h-5 w-5"
/>
<span>{provider.name}</span>
</LoginButton>
);
}

View File

@@ -1,9 +1,8 @@
import PlexIcon from '@app/assets/services/plex.svg';
import Button from '@app/components/Common/Button';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import usePlexLogin from '@app/hooks/usePlexLogin';
import defineMessages from '@app/utils/defineMessages';
import { FormattedMessage } from 'react-intl';
import LoginButton from './LoginButton';
const messages = defineMessages('components.Login', {
loginwithapp: 'Login with {appName}',
@@ -25,18 +24,12 @@ const PlexLoginButton = ({
const { loading, login } = usePlexLogin({ onAuthToken, onError });
return (
<Button
className="relative flex-grow border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)] disabled:opacity-50"
<LoginButton
className="border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)]"
onClick={login}
disabled={loading || isProcessing}
loading={loading || isProcessing}
data-testid="plex-login-button"
>
{loading && (
<div className="absolute right-0 mr-4 h-4 w-4">
<SmallLoadingSpinner />
</div>
)}
{large ? (
<FormattedMessage
{...messages.loginwithapp}
@@ -55,7 +48,7 @@ const PlexLoginButton = ({
) : (
<PlexIcon className="w-8" />
)}
</Button>
</LoginButton>
);
};

View File

@@ -1,12 +1,12 @@
import EmbyLogo from '@app/assets/services/emby-icon-only.svg';
import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg';
import PlexLogo from '@app/assets/services/plex.svg';
import Button from '@app/components/Common/Button';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
import LocalLogin from '@app/components/Login/LocalLogin';
import OidcLoginButton from '@app/components/Login/OidcLoginButton';
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
@@ -21,6 +21,7 @@ import { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { CSSTransition, SwitchTransition } from 'react-transition-group';
import useSWR from 'swr';
import LoginButton from './LoginButton';
const messages = defineMessages('components.Login', {
signin: 'Sign In',
@@ -121,10 +122,9 @@ const Login = () => {
) : (
settings.currentSettings.localLogin &&
(mediaServerLogin ? (
<Button
<LoginButton
key="jellyseerr"
data-testid="jellyseerr-login-button"
className="flex-grow bg-transparent"
onClick={() => setMediaServerLogin(false)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -134,33 +134,24 @@ const Login = () => {
className="mr-2 h-5"
/>
<span>{settings.currentSettings.applicationTitle}</span>
</Button>
</LoginButton>
) : (
<Button
<LoginButton
key="mediaserver"
data-testid="mediaserver-login-button"
className="flex-grow bg-transparent"
onClick={() => setMediaServerLogin(true)}
>
<MediaServerLogo />
<span>{mediaServerName}</span>
</Button>
</LoginButton>
))
)),
...settings.currentSettings.openIdProviders.map((provider) => (
<Button
as="a"
href={`/api/v1/auth/oidc/login/${provider.slug}`}
className="flex-grow bg-transparent"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={provider.logo || '/images/openid.svg'}
alt={provider.name}
className="mr-2 max-h-5 w-5"
/>
<span>{provider.name}</span>
</Button>
<OidcLoginButton
key={provider.slug}
provider={provider}
onError={setError}
/>
)),
].filter((o): o is JSX.Element => !!o);

View File

@@ -240,7 +240,6 @@
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.back": "Go back",
"components.Login.backtologin": "Back to Login",
"components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
"components.Login.email": "Email Address",
@@ -254,6 +253,7 @@
"components.Login.loginerror": "Something went wrong while trying to sign in.",
"components.Login.loginwithapp": "Login with {appName}",
"components.Login.noadminerror": "No admin user found on the server.",
"components.Login.oidcLoginError": "An error occurred while logging in with {provider}.",
"components.Login.orsigninwith": "Or sign in with",
"components.Login.password": "Password",
"components.Login.port": "Port",

View File

@@ -1,74 +0,0 @@
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import LoginError from '@app/components/Login/ErrorCallout';
import defineMessages from '@app/utils/defineMessages';
import { processCallback } from '@app/utils/oidc';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Login', {
loginerror: 'Something went wrong while trying to sign in.',
backtologin: 'Back to Login',
});
const OidcCallback = () => {
const router = useRouter();
const intl = useIntl();
const { slug } = router.query as { slug: string };
const login = async () => {
const params = new URLSearchParams(window.location.search);
const result = await processCallback(params, slug);
// is popup window
if (window.opener && window.opener !== window) {
// send result to the opening window
window.opener.postMessage(
result,
`${window.location.protocol}//${window.location.host}`
);
// close the popup
window.close();
} else {
if (result.type === 'success') {
// redirect to homepage
router.push(result.message?.to ?? '/');
} else {
// display login error
setError(result.message);
}
}
};
const [error, setError] = useState<string | null>(null);
useEffect(() => {
login();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="container mx-auto flex h-screen items-center justify-center">
{error != null ? (
<div className="flex flex-col items-center">
<h2 className="mb-4 text-lg text-white">
{intl.formatMessage(messages.loginerror)}
</h2>
<LoginError error={error}></LoginError>
<Link href="/login" className="text-indigo-500">
{' '}
<span className="hover:underline">
{intl.formatMessage(messages.backtologin)}
</span>
</Link>
</div>
) : (
<LoadingSpinner />
)}
</div>
);
};
export default OidcCallback;

View File

@@ -12,7 +12,7 @@ export async function processCallback(
const result = await fetch(url);
const message = await result.json();
if (result.status !== 200) {
if (!result.ok) {
return { type: 'error', message: message.message };
}
return {