diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts index b5a1818de..8adfc426b 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/pages/api/auth/[...nextauth].ts @@ -3,5 +3,5 @@ import NextAuth from 'next-auth'; import { constructAuthOptions } from '~/server/auth'; export default async function auth(req: NextApiRequest, res: NextApiResponse) { - return await NextAuth(req, res, constructAuthOptions(req, res)); + return await NextAuth(req, res, await constructAuthOptions(req, res)); } diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index eb7f0af2e..482e1db6e 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -70,7 +70,15 @@ export default function LoginPage({ }; useEffect(() => { - if (oidcAutoLogin) signIn('oidc'); + if (oidcAutoLogin && !isError) + signIn('oidc', { + redirect: false, + callbackUrl: '/', + }).then((response) => { + if (!response?.ok) { + setIsError(true); + } + }); }, [oidcAutoLogin]); const metaTitle = `${t('metaTitle')} • Homarr`; @@ -186,7 +194,17 @@ export default function LoginPage({ )} {providers.includes('oidc') && ( - )} diff --git a/src/server/auth.ts b/src/server/auth.ts index 46ea2f539..380940378 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -4,7 +4,8 @@ import { type GetServerSidePropsContext, type NextApiRequest, type NextApiRespon import { type NextAuthOptions, getServerSession } from 'next-auth'; import { Adapter } from 'next-auth/adapters'; import { decode, encode } from 'next-auth/jwt'; -import { adapter, onCreateUser, providers } from '~/utils/auth'; +import { adapter, getProviders, onCreateUser } from '~/utils/auth'; +import { createRedirectUri } from '~/utils/auth/oidc'; import EmptyNextAuthProvider from '~/utils/empty-provider'; import { fromDate, generateSessionToken } from '~/utils/session'; import { colorSchemeParser } from '~/validations/user'; @@ -19,10 +20,10 @@ const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days * * @see https://next-auth.js.org/configuration/options */ -export const constructAuthOptions = ( +export const constructAuthOptions = async ( req: NextApiRequest, res: NextApiResponse -): NextAuthOptions => ({ +): Promise => ({ events: { createUser: onCreateUser, }, @@ -86,6 +87,11 @@ export const constructAuthOptions = ( return true; }, + async redirect({ url, baseUrl }) { + const pathname = new URL(url, baseUrl).pathname; + const redirectUrl = createRedirectUri(req.headers, pathname); + return redirectUrl; + }, }, session: { strategy: 'database', @@ -96,7 +102,7 @@ export const constructAuthOptions = ( error: '/auth/login', }, adapter: adapter as Adapter, - providers: [...providers, EmptyNextAuthProvider()], + providers: [...(await getProviders(req.headers)), EmptyNextAuthProvider()], jwt: { async encode(params) { if (!isCredentialsRequest(req)) { @@ -134,14 +140,14 @@ const isCredentialsRequest = (req: NextApiRequest): boolean => { * * @see https://next-auth.js.org/configuration/nextjs */ -export const getServerAuthSession = (ctx: { +export const getServerAuthSession = async (ctx: { req: GetServerSidePropsContext['req']; res: GetServerSidePropsContext['res']; }) => { - return getServerSession( + return await getServerSession( ctx.req, ctx.res, - constructAuthOptions( + await constructAuthOptions( ctx.req as unknown as NextApiRequest, ctx.res as unknown as NextApiResponse ) diff --git a/src/utils/auth/index.ts b/src/utils/auth/index.ts index 73c7ea5db..987348625 100644 --- a/src/utils/auth/index.ts +++ b/src/utils/auth/index.ts @@ -2,6 +2,8 @@ import { DefaultSession } from 'next-auth'; import { CredentialsConfig, OAuthConfig } from 'next-auth/providers'; import { env } from '~/env'; +import { OidcRedirectCallbackHeaders } from './oidc'; + export { default as adapter, onCreateUser } from './adapter'; /** @@ -38,9 +40,16 @@ declare module 'next-auth/jwt' { } } -export const providers: (CredentialsConfig | OAuthConfig)[] = []; +export const getProviders = async (headers: OidcRedirectCallbackHeaders) => { + const providers: (CredentialsConfig | OAuthConfig)[] = []; -if (env.AUTH_PROVIDER?.includes('ldap')) providers.push((await import('./ldap')).default); -if (env.AUTH_PROVIDER?.includes('credentials')) - providers.push((await import('./credentials')).default); -if (env.AUTH_PROVIDER?.includes('oidc')) providers.push((await import('./oidc')).default); + if (env.AUTH_PROVIDER?.includes('ldap')) providers.push((await import('./ldap')).default); + if (env.AUTH_PROVIDER?.includes('credentials')) + providers.push((await import('./credentials')).default); + if (env.AUTH_PROVIDER?.includes('oidc')) { + const createProvider = (await import('./oidc')).default; + providers.push(createProvider(headers)); + } + + return providers; +}; diff --git a/src/utils/auth/oidc-redirect.spec.ts b/src/utils/auth/oidc-redirect.spec.ts new file mode 100644 index 000000000..166abb74c --- /dev/null +++ b/src/utils/auth/oidc-redirect.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest'; + +import { createRedirectUri } from './oidc'; + +describe('redirect', () => { + test('Callback should return http url when not defining protocol', async () => { + // Arrange + const headers = { + 'x-forwarded-host': 'localhost:3000', + }; + + // Act + const result = await createRedirectUri(headers, '/api/auth/callback/oidc'); + + // Assert + expect(result).toBe('http://localhost:3000/api/auth/callback/oidc'); + }); + + test('Callback should return https url when defining protocol', async () => { + // Arrange + const headers = { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'localhost:3000', + }; + + // Act + const result = await createRedirectUri(headers, '/api/auth/callback/oidc'); + + // Assert + expect(result).toBe('https://localhost:3000/api/auth/callback/oidc'); + }); + + test('Callback should return https url when defining protocol and host', async () => { + // Arrange + const headers = { + 'x-forwarded-proto': 'https', + host: 'something.else', + }; + + // Act + const result = await createRedirectUri(headers, '/api/auth/callback/oidc'); + + // Assert + expect(result).toBe('https://something.else/api/auth/callback/oidc'); + }); + + test('Callback should return https url when defining protocol as http,https and host', async () => { + // Arrange + const headers = { + 'x-forwarded-proto': 'http,https', + 'x-forwarded-host': 'hello.world', + }; + + // Act + const result = await createRedirectUri(headers, '/api/auth/callback/oidc'); + + // Assert + expect(result).toBe('https://hello.world/api/auth/callback/oidc'); + }); +}); diff --git a/src/utils/auth/oidc.ts b/src/utils/auth/oidc.ts index 47076275e..f1d3e58a9 100644 --- a/src/utils/auth/oidc.ts +++ b/src/utils/auth/oidc.ts @@ -13,14 +13,42 @@ type Profile = { email_verified: boolean; }; -const provider: OAuthConfig = { +export type OidcRedirectCallbackHeaders = { + 'x-forwarded-proto'?: string; + 'x-forwarded-host'?: string; + host?: string; +}; + +// The redirect_uri is constructed to work behind a reverse proxy. It is constructed from the headers x-forwarded-proto and x-forwarded-host. +export const createRedirectUri = (headers: OidcRedirectCallbackHeaders, pathname: string) => { + let protocol = headers['x-forwarded-proto'] ?? 'http'; + + // @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219 + if (protocol.includes(',')) { + protocol = protocol.includes('https') ? 'https' : 'http'; + } + + const path = pathname.startsWith('/') ? pathname : `/${pathname}`; + + const host = headers['x-forwarded-host'] ?? headers.host; + + + return `${protocol}://${host}${path}`; +}; + +const createProvider = (headers: OidcRedirectCallbackHeaders): OAuthConfig => ({ id: 'oidc', name: env.AUTH_OIDC_CLIENT_NAME, type: 'oauth', clientId: env.AUTH_OIDC_CLIENT_ID, clientSecret: env.AUTH_OIDC_CLIENT_SECRET, wellKnown: `${env.AUTH_OIDC_URI}/.well-known/openid-configuration`, - authorization: { params: { scope: env.AUTH_OIDC_SCOPE_OVERWRITE } }, + authorization: { + params: { + scope: env.AUTH_OIDC_SCOPE_OVERWRITE, + redirect_uri: createRedirectUri(headers, '/api/auth/callback/oidc'), + }, + }, idToken: true, async profile(profile) { const user = await adapter.getUserByEmail!(profile.email); @@ -50,6 +78,6 @@ const provider: OAuthConfig = { isOwner, }; }, -}; +}); -export default provider; +export default createProvider;