Fix redirect OIDC (#1911)

* fix: redirect oidc not defined

* fix: construct redirect url from x-forwarded prot and host

* fix: redirect method does not support comma seperated protocol list and url starting with http(s)://

* fix: redirect_uri not specified for oidc as authorization parameter

* fix: unit test not modified

* docs: add comment why the redirect_uri is constructed

* fix: add redirect callback with forwarded headers as redirect url host and protocol

* Apply suggestions from code review
This commit is contained in:
Meier Lukas
2024-02-20 20:34:57 +01:00
committed by GitHub
parent 1bc19e7857
commit 5cd940f3cc
6 changed files with 140 additions and 19 deletions

View File

@@ -3,5 +3,5 @@ import NextAuth from 'next-auth';
import { constructAuthOptions } from '~/server/auth'; import { constructAuthOptions } from '~/server/auth';
export default async function auth(req: NextApiRequest, res: NextApiResponse) { 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));
} }

View File

@@ -70,7 +70,15 @@ export default function LoginPage({
}; };
useEffect(() => { useEffect(() => {
if (oidcAutoLogin) signIn('oidc'); if (oidcAutoLogin && !isError)
signIn('oidc', {
redirect: false,
callbackUrl: '/',
}).then((response) => {
if (!response?.ok) {
setIsError(true);
}
});
}, [oidcAutoLogin]); }, [oidcAutoLogin]);
const metaTitle = `${t('metaTitle')} • Homarr`; const metaTitle = `${t('metaTitle')} • Homarr`;
@@ -186,7 +194,17 @@ export default function LoginPage({
<Divider label="OIDC" labelPosition="center" mt="xl" mb="md" /> <Divider label="OIDC" labelPosition="center" mt="xl" mb="md" />
)} )}
{providers.includes('oidc') && ( {providers.includes('oidc') && (
<Button mt="xs" variant="light" fullWidth onClick={() => signIn('oidc')}> <Button
mt="xs"
variant="light"
fullWidth
onClick={() =>
signIn('oidc', {
redirect: false,
callbackUrl: '/',
})
}
>
{t('form.buttons.submit')} - {oidcProviderName} {t('form.buttons.submit')} - {oidcProviderName}
</Button> </Button>
)} )}

View File

@@ -4,7 +4,8 @@ import { type GetServerSidePropsContext, type NextApiRequest, type NextApiRespon
import { type NextAuthOptions, getServerSession } from 'next-auth'; import { type NextAuthOptions, getServerSession } from 'next-auth';
import { Adapter } from 'next-auth/adapters'; import { Adapter } from 'next-auth/adapters';
import { decode, encode } from 'next-auth/jwt'; 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 EmptyNextAuthProvider from '~/utils/empty-provider';
import { fromDate, generateSessionToken } from '~/utils/session'; import { fromDate, generateSessionToken } from '~/utils/session';
import { colorSchemeParser } from '~/validations/user'; 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 * @see https://next-auth.js.org/configuration/options
*/ */
export const constructAuthOptions = ( export const constructAuthOptions = async (
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
): NextAuthOptions => ({ ): Promise<NextAuthOptions> => ({
events: { events: {
createUser: onCreateUser, createUser: onCreateUser,
}, },
@@ -86,6 +87,11 @@ export const constructAuthOptions = (
return true; return true;
}, },
async redirect({ url, baseUrl }) {
const pathname = new URL(url, baseUrl).pathname;
const redirectUrl = createRedirectUri(req.headers, pathname);
return redirectUrl;
},
}, },
session: { session: {
strategy: 'database', strategy: 'database',
@@ -96,7 +102,7 @@ export const constructAuthOptions = (
error: '/auth/login', error: '/auth/login',
}, },
adapter: adapter as Adapter, adapter: adapter as Adapter,
providers: [...providers, EmptyNextAuthProvider()], providers: [...(await getProviders(req.headers)), EmptyNextAuthProvider()],
jwt: { jwt: {
async encode(params) { async encode(params) {
if (!isCredentialsRequest(req)) { if (!isCredentialsRequest(req)) {
@@ -134,14 +140,14 @@ const isCredentialsRequest = (req: NextApiRequest): boolean => {
* *
* @see https://next-auth.js.org/configuration/nextjs * @see https://next-auth.js.org/configuration/nextjs
*/ */
export const getServerAuthSession = (ctx: { export const getServerAuthSession = async (ctx: {
req: GetServerSidePropsContext['req']; req: GetServerSidePropsContext['req'];
res: GetServerSidePropsContext['res']; res: GetServerSidePropsContext['res'];
}) => { }) => {
return getServerSession( return await getServerSession(
ctx.req, ctx.req,
ctx.res, ctx.res,
constructAuthOptions( await constructAuthOptions(
ctx.req as unknown as NextApiRequest, ctx.req as unknown as NextApiRequest,
ctx.res as unknown as NextApiResponse ctx.res as unknown as NextApiResponse
) )

View File

@@ -2,6 +2,8 @@ import { DefaultSession } from 'next-auth';
import { CredentialsConfig, OAuthConfig } from 'next-auth/providers'; import { CredentialsConfig, OAuthConfig } from 'next-auth/providers';
import { env } from '~/env'; import { env } from '~/env';
import { OidcRedirectCallbackHeaders } from './oidc';
export { default as adapter, onCreateUser } from './adapter'; export { default as adapter, onCreateUser } from './adapter';
/** /**
@@ -38,9 +40,16 @@ declare module 'next-auth/jwt' {
} }
} }
export const providers: (CredentialsConfig | OAuthConfig<any>)[] = []; export const getProviders = async (headers: OidcRedirectCallbackHeaders) => {
const providers: (CredentialsConfig | OAuthConfig<any>)[] = [];
if (env.AUTH_PROVIDER?.includes('ldap')) providers.push((await import('./ldap')).default); if (env.AUTH_PROVIDER?.includes('ldap')) providers.push((await import('./ldap')).default);
if (env.AUTH_PROVIDER?.includes('credentials')) if (env.AUTH_PROVIDER?.includes('credentials'))
providers.push((await import('./credentials')).default); providers.push((await import('./credentials')).default);
if (env.AUTH_PROVIDER?.includes('oidc')) providers.push((await import('./oidc')).default); if (env.AUTH_PROVIDER?.includes('oidc')) {
const createProvider = (await import('./oidc')).default;
providers.push(createProvider(headers));
}
return providers;
};

View File

@@ -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');
});
});

View File

@@ -13,14 +13,42 @@ type Profile = {
email_verified: boolean; email_verified: boolean;
}; };
const provider: OAuthConfig<Profile> = { 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<Profile> => ({
id: 'oidc', id: 'oidc',
name: env.AUTH_OIDC_CLIENT_NAME, name: env.AUTH_OIDC_CLIENT_NAME,
type: 'oauth', type: 'oauth',
clientId: env.AUTH_OIDC_CLIENT_ID, clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET, clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
wellKnown: `${env.AUTH_OIDC_URI}/.well-known/openid-configuration`, 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, idToken: true,
async profile(profile) { async profile(profile) {
const user = await adapter.getUserByEmail!(profile.email); const user = await adapter.getUserByEmail!(profile.email);
@@ -50,6 +78,6 @@ const provider: OAuthConfig<Profile> = {
isOwner, isOwner,
}; };
}, },
}; });
export default provider; export default createProvider;