mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-07 07:09:21 +01:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
60
src/utils/auth/oidc-redirect.spec.ts
Normal file
60
src/utils/auth/oidc-redirect.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user