adminjs-nestjs
adminjs-nestjs copied to clipboard
Custom authentication support
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 were you able to solve this one? are there updates regarding this feature?
@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.
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:
- 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 theadmin
instance to it - 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);
};
- now you can easily create the
withLogin
function you want and replace it with the one above. remember thatwithLogin
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);
}
});
},
);
};
- 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 theGET
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;
- 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
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.
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.