adminjs-nestjs icon indicating copy to clipboard operation
adminjs-nestjs copied to clipboard

Custom authentication support

Open KCFindstr opened this issue 3 years ago • 5 comments

I tried to use the adminbro nestjs module with oauth authentication. The auth option does not work for me because the user should be redirected to an identity server to log in, instead showing the login page. Is there a way I can provide my own authentication router? I noticed that it seems possible with the original adminbro library (https://github.com/SoftwareBrothers/admin-bro/issues/546) but I'm not sure how to use it with nestjs module.

KCFindstr avatar Apr 23 '21 04:04 KCFindstr

@KCFindstr were you able to solve this one? are there updates regarding this feature?

loagencydev avatar Jun 13 '22 18:06 loagencydev

@KCFindstr were you able to solve this one?

are there updates regarding this feature?

I tried to get around this by creating my own adminjs loader:

  • I redirect adminjs login route to the identity server.
  • In the OAuth callback route, I create a session if the user is admin.
  • Write a loader with a middleware that checks all requests to adminjs routes and reject if session is invalid.
import { Injectable } from '@nestjs/common';
import { AbstractHttpAdapter } from '@nestjs/core';
import { AdminModuleOptions, AbstractLoader } from '@adminjs/nestjs';
import AdminJS from 'adminjs';
import adminJsExpressjs from '@adminjs/express';

// This file is modified from https://github.com/SoftwareBrothers/adminjs-nestjs/blob/fcd978e8db80b69766d3736b231e89be0f800d86/src/loaders/express.loader.ts
@Injectable()
export class AdminLoader implements AbstractLoader {
  public register(
    admin: AdminJS,
    httpAdapter: AbstractHttpAdapter,
    options: AdminModuleOptions,
  ) {
    const app = httpAdapter.getInstance();

    const router = adminJsExpressjs.buildRouter(
      admin,
      undefined,
      options.formidableOptions,
    );

    // This named function is there on purpose.
    // It names layer in main router with the name of the function, which helps localize
    // admin layer in reorderRoutes() step.
    app.use(options.adminJsOptions.rootPath, function admin(req, res, next) {
      const session: any = req.session;
      if (!session.adminUser) {
        return res.redirect(options.adminJsOptions.loginPath);
      }
      return router(req, res, next);
    });
    this.reorderRoutes(app);
  }

  private reorderRoutes(app) {
    let jsonParser;
    let urlencodedParser;
    let admin;

    // Nestjs uses bodyParser under the hood which is in conflict with adminjs setup.
    // Due to adminjs-expressjs usage of formidable we have to move body parser in layer tree after adminjs init.
    // Notice! This is not documented feature of express, so this may change in the future. We have to keep an eye on it.
    if (app && app._router && app._router.stack) {
      const jsonParserIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'jsonParser',
      );
      if (jsonParserIndex >= 0) {
        jsonParser = app._router.stack.splice(jsonParserIndex, 1);
      }

      const urlencodedParserIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'urlencodedParser',
      );
      if (urlencodedParserIndex >= 0) {
        urlencodedParser = app._router.stack.splice(urlencodedParserIndex, 1);
      }

      const adminIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'admin',
      );
      if (adminIndex >= 0) {
        admin = app._router.stack.splice(adminIndex, 1);
      }

      // if adminjs-nestjs didn't reorder the middleware
      // the body parser would have come after corsMiddleware
      const corsIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'corsMiddleware',
      );

      // in other case if there is no corsIndex we go after expressInit, because right after that
      // there are nest endpoints.
      const expressInitIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'expressInit',
      );

      const initIndex = (corsIndex >= 0 ? corsIndex : expressInitIndex) + 1;

      app._router.stack.splice(
        initIndex,
        0,
        ...admin,
        ...jsonParser,
        ...urlencodedParser,
      );
    }
  }
}

I wrote this a while ago so I'm not sure if it still works with current version of adminjs, and I have no idea if there's official custom authentication support, either.

KCFindstr avatar Jun 13 '22 19:06 KCFindstr

great @KCFindstr! thanks for the solution, I actually had to do the same. I basically overwrote the routing logic of AdminJS and used passport to integrate with the oauth I wanted.

for anyone who wants to do the same later:

  1. remember that everything begins with router.use(admin.options.rootPath, Auth.buildAuthenticatedRouter(admin));, so all you have to do is to create a function which does almost the same, and pass the admin instance to it
  2. the buildAuthenticatedRouter has the following signature:
function buildAuthenticatedRouter(admin: AdminJS, predefinedRouter?: express.Router | null, formidableOptions?: FormidableOptions): Router

here's how mine looks like atm:

export const buildAuthenticatedRouter = (
	admin: AdminJS,
	predefinedRouter?: express.Router | null,
	formidableOptions?: FormidableOptions,
): Router => {
	const router = predefinedRouter || express.Router();

	router.use((req, _, next) => {
		if ((req as any)._body) {
			next(new OldBodyParserUsedError());
		}
		next();
	});

	router.use(formidableMiddleware(formidableOptions));

	withProtectedAdminRoutesHandler(router, admin);
	withLogin(router, admin); // <-- this function is what we need
	withLogout(router, admin);

	return buildRouter(admin, router, formidableOptions);
};
  1. now you can easily create the withLogin function you want and replace it with the one above. remember that withLogin will set up the routes that can only be accessed if the user/session is authenticated.
export const withLogin = (router: Router, admin: AdminJS): void => {
	const { rootPath } = admin.options;
	const loginPath = getLoginPath(admin);

	const callbackPath = `${config.admin.path}/${loginPath}/callback`;
	const authPath = `${config.admin.path}/${loginPath}/auth`;

	passport.use(
		new OAuth2Strategy(
			{
				// ...configs that you need
			},
			function (
				accessToken: string,
				refreshToken: string,
				profile: any,
				cb: (...args: any[]) => any,
			) {
				// you probably want to check some stuff here.

				const decoded: any = jwt.decode(accessToken);

				const userSession: CurrentAdmin = {
					title: decoded["name"],
					email: decoded["email"],
					id: decoded["sid"],
					avatarUrl:
						decoded["profile"] ||
						`https://ui-avatars.com/api/?name=${(decoded["name"] as string).replace(
							" ",
							"+",
						)}`,
				};

				return cb(null, userSession);
			},
		),
	);

	// this route will only render the login page you have. take note that this must be overriden,
	// as most probably you don't want to directly get the user's username/pass.
	router.get(loginPath, async (_, res) => {
		const login = await renderLogin(admin, {
			action: authPath,
		});

		res.send(login);
	});

	router.get(path.join(loginPath, "auth"), passport.authenticate("oauth2"));
	router.get(
		path.join(loginPath, "callback"),
		passport.authenticate("oauth2", { failureRedirect: `${config.admin.path}/login` }),
		(req, res, next) => {
			(req.session as any).adminUser = (req.session as any).passport.user;
			req.session.save((err) => {
				if (err) {
					next(err);
				}

				if ((req.session as any).redirectTo) {
					res.redirect(302, (req.session as any).redirectTo);
				} else {
					res.redirect(302, rootPath);
				}
			});
		},
	);
};
  1. now since I wanted to redirect the user from the login page to my auth provider, where they can give their user/pass, I also had to rewrite the login page. the key for this is to create a renderLogin function, and replace it with the one that is used in the GET route:
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { combineStyles } from "@adminjs/design-system";
import i18n from "i18next";
import React from "react";
import { renderToString } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { Provider } from "react-redux";
import { Store } from "redux";
import { ServerStyleSheet, StyleSheetManager, ThemeProvider } from "styled-components";
import AdminJS, {
	createStore,
	getAssets,
	getBranding,
	getFaviconFromBranding,
	initializeAssets,
	initializeBranding,
	initializeLocale,
	ReduxState,
	ViewHelpers,
} from "adminjs";
import LoginComponent from "./login-component";

type LoginTemplateAttributes = {
	/**
	 * action which should be called when user clicks submit button
	 */
	action: string;
	/**
	 * Error message to present in the form
	 */
	errorMessage?: string;
};

const html = async (admin: AdminJS, { action, errorMessage }: LoginTemplateAttributes): Promise<string> => {
	const h = new ViewHelpers({ options: admin.options });

	const store: Store<ReduxState> = createStore();

	const branding = await getBranding(admin);
	const assets = await getAssets(admin);
	const faviconTag = getFaviconFromBranding(branding);

	const scripts = ((assets && assets.scripts) || []).map((s) => `<script src="${s}"></script>`);
	const styles = ((assets && assets.styles) || []).map(
		(l) => `<link rel="stylesheet" type="text/css" href="${l}">`,
	);

	store.dispatch(initializeBranding(branding));
	store.dispatch(initializeAssets(assets));
	store.dispatch(initializeLocale(admin.locale));

	const theme = combineStyles((branding && branding.theme) || {});
	const { locale } = store.getState();
	i18n.init({
		resources: {
			[locale.language]: {
				translation: locale.translations,
			},
		},
		lng: locale.language,
		interpolation: { escapeValue: false },
	});

	const sheet = new ServerStyleSheet();

	const loginComponent = renderToString(
		<StyleSheetManager sheet={sheet.instance}>
			<Provider store={store}>
				<I18nextProvider i18n={i18n}>
					<ThemeProvider theme={theme}>
						<LoginComponent action={action} message={errorMessage} />
					</ThemeProvider>
				</I18nextProvider>
			</Provider>
		</StyleSheetManager>,
	);

	sheet.collectStyles(<LoginComponent action={action} message={errorMessage} />);
	const style = sheet.getStyleTags();
	sheet.seal();

	return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
      <title>${branding.companyName}</title>
      ${style}
      ${faviconTag}
      <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,700" type="text/css">
      ${styles.join("\n")}

      <script src="${h.assetPath("global.bundle.js", assets)}"></script>
      <script src="${h.assetPath("design-system.bundle.js", assets)}"></script>
    </head>
    <body>
      <div id="app">${loginComponent}</div>
      ${scripts.join("\n")}
    </body>
    </html>
  `;
};

export default html;
  1. Now the last step (I promise) is to create a LoginComponent react component, which does what ever you want it to:
import React from "react";
import styled, { createGlobalStyle } from "styled-components";

import { useSelector } from "react-redux";
import { Box, H5, Button, Text, MessageBox } from "@adminjs/design-system";
import { ReduxState, useTranslation } from "adminjs";

const GlobalStyle = createGlobalStyle`
  html, body, #app {
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
  }
`;

const Wrapper = styled(Box)`
	align-items: center;
	justify-content: center;
	flex-direction: column;
	height: 100%;
	text-align: center;
`;

const StyledLogo = styled.img`
	max-width: 200px;
	margin: 1em 0;
`;

export type LoginProps = {
	message: string | undefined;
	action: string;
};

export const Login: React.FC<LoginProps> = (props) => {
	const { action, message } = props;
	const { translateMessage } = useTranslation();
	const branding = useSelector((state: ReduxState) => state.branding);

	return (
		<React.Fragment>
			<GlobalStyle />
			<Wrapper flex variant="grey">
				<Box bg="white" height="440px" flex boxShadow="login" width={[1, 2 / 3, "auto"]}>
					<Box
						as="form"
						action={action}
						method="GET"
						p="x3"
						flexGrow={1}
						width={["100%", "100%", "480px"]}
						style={{
							alignSelf: "center",
						}}>
						<H5 marginBottom="xxl">
							{branding.logo ? (
								<StyledLogo
									src={branding.logo}
									alt={branding.companyName}
								/>
							) : (
								branding.companyName
							)}
						</H5>
						{message && (
							<MessageBox
								my="lg"
								message={
									message.split(" ").length > 1
										? message
										: translateMessage(message)
								}
								variant="danger"
							/>
						)}
						<Text mt="xl" textAlign="center">
							<Button variant="primary">Login with LoID</Button>
						</Text>
					</Box>
				</Box>
			</Wrapper>
		</React.Fragment>
	);
};

export default Login;

Now my users can perfectly login using any authentication method I want :)

hope this help y'all

AienTech avatar Jun 15 '22 18:06 AienTech

This looks like exactly what I need, have you put files on GitHub? Also want to make sure I make the right attribution and checking on usage rights.

amygooch avatar Jul 05 '22 21:07 amygooch

This looks like exactly what I need, have you put files on GitHub? Also want to make sure I make the right attribution and checking on usage rights.

I can't say for @AienTech but feel free to use my code - it's just adminjs's source code with very few modifications.

KCFindstr avatar Jul 06 '22 05:07 KCFindstr