fix: improve error handling, process callback on login page
This commit is contained in:
@@ -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: []
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
|
||||
35
src/components/Login/LoginButton.tsx
Normal file
35
src/components/Login/LoginButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/Login/OidcLoginButton.tsx
Normal file
90
src/components/Login/OidcLoginButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user