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';
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user