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';
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(() => {
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({
<Divider label="OIDC" labelPosition="center" mt="xl" mb="md" />
)}
{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}
</Button>
)}

View File

@@ -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<NextAuthOptions> => ({
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
)

View File

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

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;
};
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',
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<Profile> = {
isOwner,
};
},
};
});
export default provider;
export default createProvider;