diff --git a/src/adminjs-options.interface.ts b/src/adminjs-options.interface.ts index 8fe314fab..dd8ec45e4 100644 --- a/src/adminjs-options.interface.ts +++ b/src/adminjs-options.interface.ts @@ -467,6 +467,7 @@ export interface AdminJSOptionsWithDefault extends AdminJSOptions { rootPath: string; logoutPath: string; loginPath: string; + refreshTokenPath: string; databases?: Array; resources?: Array< | BaseResource diff --git a/src/adminjs.ts b/src/adminjs.ts index 9fef940fd..8804f8f81 100644 --- a/src/adminjs.ts +++ b/src/adminjs.ts @@ -1,7 +1,6 @@ import merge from 'lodash/merge.js' import * as path from 'path' import * as fs from 'fs' -import type { FC } from 'react' import * as url from 'url' import { AdminJSOptionsWithDefault, AdminJSOptions } from './adminjs-options.interface.js' @@ -14,7 +13,7 @@ import { RecordActionResponse, Action, BulkActionResponse } from './backend/acti import { DEFAULT_PATHS } from './constants.js' import { ACTIONS } from './backend/actions/index.js' -import loginTemplate from './frontend/login-template.js' +import loginTemplate, { LoginTemplateAttributes } from './frontend/login-template.js' import { ListActionResponse } from './backend/actions/list/list-action.js' import { Locale } from './locale/index.js' import { TranslateFunctions } from './utils/translate-functions.factory.js' @@ -31,6 +30,7 @@ export const defaultOptions: AdminJSOptionsWithDefault = { rootPath: DEFAULT_PATHS.rootPath, logoutPath: DEFAULT_PATHS.logoutPath, loginPath: DEFAULT_PATHS.loginPath, + refreshTokenPath: DEFAULT_PATHS.refreshTokenPath, databases: [], resources: [], dashboard: {}, @@ -47,11 +47,6 @@ type ActionsMap = { list: Action; } -export type LoginOverride> = { - component: FC; - props?: T; -} - export type Adapter = { Database: typeof BaseDatabase; Resource: typeof BaseResource } /** @@ -91,11 +86,6 @@ class AdminJS { */ public static VERSION: string - /** - * Login override - */ - private loginOverride?: LoginOverride - /** * @param {AdminJSOptions} options Options passed to AdminJS */ @@ -196,8 +186,8 @@ class AdminJS { * the form * @return {Promise} HTML of the rendered page */ - async renderLogin({ action, errorMessage }): Promise { - return loginTemplate(this, { action, errorMessage }) + async renderLogin(props: LoginTemplateAttributes): Promise { + return loginTemplate(this, props) } /** diff --git a/src/backend/utils/auth/base-auth-provider.ts b/src/backend/utils/auth/base-auth-provider.ts new file mode 100644 index 000000000..567c7d948 --- /dev/null +++ b/src/backend/utils/auth/base-auth-provider.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable max-len */ +/* eslint-disable class-methods-use-this */ +import { CurrentAdmin } from '../../../current-admin.interface.js' +import { ComponentLoader } from '../component-loader.js' +import { NotImplementedError } from '../errors/index.js' + +export interface AuthenticatePayload { + [key: string]: any; +} + +export interface AuthProviderConfig { + componentLoader: ComponentLoader; + authenticate: (payload: T, context?: any) => Promise; +} + +export interface LoginHandlerOptions { + data: Record; + query?: Record; + params?: Record; + headers: Record; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface RefreshTokenHandlerOptions extends LoginHandlerOptions {} + +/** + * Extendable class which includes methods allowing you to build custom auth providers or modify existing ones. + * + * Documentation: https://docs.adminjs.co/basics/authentication + */ +export class BaseAuthProvider { + /** + * "getUiProps" method should be used to decide which configuration variables are needed + * in the frontend. By default it returns an empty object. + * + * @returns an object sent to the frontend app, available in `window.__APP_STATE__` + */ + public getUiProps(): Record { + return {} + } + + /** + * Handle login action of user. The method should return a user object or null. + * + * @param opts Basic REST request data: data (body), query, params, headers + * @param context Full request context specific to your framework, i. e. "request" and "response" in Express + */ + public async handleLogin(opts: LoginHandlerOptions, context?: TContext): Promise { + throw new NotImplementedError('BaseAuthProvider#handleLogin') + } + + /** + * "handleLogout" allows you to perform extra actions to log out the user, you have access to request's context. + * For example, you could want to log out the user from external services besides destroying AdminJS session. + * By default, this method is always called by your framework plugin but does nothing. + * + * @param context Full request context specific to your framework, i. e. "request" and "response" in Express + * @returns Returns anything, but the default plugin implementations don't do anything with the result. + */ + public async handleLogout(context?: TContext): Promise { + return Promise.resolve() + } + + /** + * This method is assigned to an endpoint at your server's AdminJS "refreshTokenPath". It is not used by default. + * In order to use this API Endpoint, override "AuthenticationBackgroundComponent" by using your ComponentLoader instance. + * You can use that component to call API to refresh your user's session when specific conditions are met. The default + * email/password authentication doesn't require you to refresh your session, but you may want to use "handleRefreshToken" + * in case your authentication is integrated with an external IdP which issues short-lived access tokens. + * + * Any authentication metadata should ideally be stored under "_auth" property of CurrentAdmin. + * + * See more in the documentation: https://docs.adminjs.co/basics/authentication + * + * @param opts Basic REST request data: data (body), query, params, headers + * @param context Full request context specific to your framework, i. e. "request" and "response" in Express + * @returns Updated session object to be merged with existing one. + */ + public async handleRefreshToken(opts: RefreshTokenHandlerOptions, context?: TContext): Promise { + return Promise.resolve({}) + } +} diff --git a/src/backend/utils/auth/default-auth-provider.ts b/src/backend/utils/auth/default-auth-provider.ts new file mode 100644 index 000000000..287536900 --- /dev/null +++ b/src/backend/utils/auth/default-auth-provider.ts @@ -0,0 +1,25 @@ +import { AuthProviderConfig, AuthenticatePayload, BaseAuthProvider, LoginHandlerOptions } from './base-auth-provider.js' + +export interface DefaultAuthenticatePayload extends AuthenticatePayload { + email: string; + password: string; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface DefaultAuthProviderConfig extends AuthProviderConfig {} + +export class DefaultAuthProvider extends BaseAuthProvider { + protected readonly authenticate + + constructor({ authenticate }: DefaultAuthProviderConfig) { + super() + this.authenticate = authenticate + } + + override async handleLogin(opts: LoginHandlerOptions, context) { + const { data = {} } = opts + const { email, password } = data + + return this.authenticate({ email, password }, context) + } +} diff --git a/src/backend/utils/auth/index.ts b/src/backend/utils/auth/index.ts new file mode 100644 index 000000000..b8324fd6b --- /dev/null +++ b/src/backend/utils/auth/index.ts @@ -0,0 +1,2 @@ +export * from './base-auth-provider.js' +export * from './default-auth-provider.js' diff --git a/src/backend/utils/component-loader.ts b/src/backend/utils/component-loader.ts index 455c77ad6..de5d74706 100644 --- a/src/backend/utils/component-loader.ts +++ b/src/backend/utils/component-loader.ts @@ -168,5 +168,6 @@ export class ComponentLoader { 'PropertyDescription', 'PropertyLabel', 'Login', + 'AuthenticationBackgroundComponent', ] } diff --git a/src/backend/utils/index.ts b/src/backend/utils/index.ts index 4d8e14da8..67d69687d 100644 --- a/src/backend/utils/index.ts +++ b/src/backend/utils/index.ts @@ -1,3 +1,4 @@ +export * from './auth/index.js' export * from './build-feature/index.js' export * from './errors/index.js' export * from './filter/index.js' diff --git a/src/constants.ts b/src/constants.ts index bd9a6c58e..9a5698c8a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,7 @@ export const DEFAULT_PATHS = { rootPath: '/admin', logoutPath: '/admin/logout', loginPath: '/admin/login', + refreshTokenPath: '/admin/refresh-token', } const DEFAULT_TMP_DIR = '.adminjs' diff --git a/src/current-admin.interface.ts b/src/current-admin.interface.ts index bcb836e88..04636671d 100644 --- a/src/current-admin.interface.ts +++ b/src/current-admin.interface.ts @@ -10,7 +10,7 @@ * @alias CurrentAdmin * @memberof AdminJS */ -export type CurrentAdmin = { +export interface CurrentAdmin { /** * Admin has one required field which is an email */ @@ -31,6 +31,10 @@ export type CurrentAdmin = { * Optional ID of theme to use */ theme?: string; + /** + * Extra metadata specific to given Auth Provider + */ + _auth?: Record; /** * Also you can put as many other fields to it as you like. */ diff --git a/src/frontend/components/app/auth-background-component.tsx b/src/frontend/components/app/auth-background-component.tsx new file mode 100644 index 000000000..2849d1b6e --- /dev/null +++ b/src/frontend/components/app/auth-background-component.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +import allowOverride from '../../hoc/allow-override.js' + +const AuthenticationBackgroundComponent: React.FC = () => null + +const OverridableAuthenticationBackgroundComponent = allowOverride(AuthenticationBackgroundComponent, 'AuthenticationBackgroundComponent') + +export { + OverridableAuthenticationBackgroundComponent as default, + OverridableAuthenticationBackgroundComponent as AuthenticationBackgroundComponent, + AuthenticationBackgroundComponent as OriginalAuthenticationBackgroundComponent, +} diff --git a/src/frontend/components/app/index.ts b/src/frontend/components/app/index.ts index 1e9e9eed2..600000163 100644 --- a/src/frontend/components/app/index.ts +++ b/src/frontend/components/app/index.ts @@ -2,6 +2,7 @@ export * from './action-button/index.js' export * from './action-header/index.js' export * from './admin-modal.js' export * from './app-loader.js' +export * from './auth-background-component.js' export * from './base-action-component.js' export * from './breadcrumbs.js' export * from './default-dashboard.js' diff --git a/src/frontend/components/application.tsx b/src/frontend/components/application.tsx index a8ef24a13..8c8d581ac 100644 --- a/src/frontend/components/application.tsx +++ b/src/frontend/components/application.tsx @@ -19,6 +19,7 @@ import { ResourceRoute, } from './routes/index.js' import useHistoryListen from '../hooks/use-history-listen.js' +import { AuthenticationBackgroundComponent } from './app/auth-background-component.js' const h = new ViewHelpers() @@ -83,8 +84,15 @@ const App: React.FC = () => { + ) } -export default allowOverride(App, 'Application') +const OverridableApp = allowOverride(App, 'Application') + +export { + OverridableApp as default, + OverridableApp as App, + App as OriginalApp, +} diff --git a/src/frontend/hoc/allow-override.tsx b/src/frontend/hoc/allow-override.tsx index 13262aba2..9fbcb12d1 100644 --- a/src/frontend/hoc/allow-override.tsx +++ b/src/frontend/hoc/allow-override.tsx @@ -7,8 +7,7 @@ import { OverridableComponent } from '../utils/overridable-component.js' * @private * * @classdesc - * Overrides one of the component form AdminJS core when user pass its name to - * {@link ComponentLoader.add} or {@link ComponentLoader.override} method. + * Overrides one of the AdminJS core components when user passes it's name to ComponentLoader * * If case of being overridden, component receives additional prop: `OriginalComponent` * diff --git a/src/frontend/login-template.tsx b/src/frontend/login-template.tsx index 553289341..191921f87 100644 --- a/src/frontend/login-template.tsx +++ b/src/frontend/login-template.tsx @@ -12,14 +12,9 @@ import { getAssets, getBranding, getFaviconFromBranding, getLocales } from '../b import { defaultLocale } from '../locale/index.js' export type LoginTemplateAttributes = { - /** - * action which should be called when user clicks submit button - */ - action: string; - /** - * Error message to present in the form - */ - errorMessage?: string; + errorMessage?: string | null; + action?: string; + [name: string]: any; } const html = async ( diff --git a/src/frontend/utils/api-client.ts b/src/frontend/utils/api-client.ts index b029689e1..952d72dda 100644 --- a/src/frontend/utils/api-client.ts +++ b/src/frontend/utils/api-client.ts @@ -250,6 +250,17 @@ class ApiClient { checkResponse(response) return response } + + async refreshToken(data: Record) { + const response = await this.client.request({ + url: '/refresh-token', + method: 'POST', + data, + }) + checkResponse(response) + + return response + } } export { diff --git a/src/frontend/utils/overridable-component.ts b/src/frontend/utils/overridable-component.ts index 5a41af890..5ee4b057f 100644 --- a/src/frontend/utils/overridable-component.ts +++ b/src/frontend/utils/overridable-component.ts @@ -78,6 +78,7 @@ export type OverridableComponent = | 'PropertyDescription' | 'PropertyLabel' | 'Login' + | 'AuthenticationBackgroundComponent' /** * Name of the components which can be overridden by ComponentLoader.