Skip to content

Commit

Permalink
feat: add support for auth providers
Browse files Browse the repository at this point in the history
  • Loading branch information
dziraf committed Nov 21, 2023
1 parent b51b012 commit 972c5de
Show file tree
Hide file tree
Showing 16 changed files with 162 additions and 26 deletions.
1 change: 1 addition & 0 deletions src/adminjs-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ export interface AdminJSOptionsWithDefault extends AdminJSOptions {
rootPath: string;
logoutPath: string;
loginPath: string;
refreshTokenPath: string;
databases?: Array<BaseDatabase>;
resources?: Array<
| BaseResource
Expand Down
18 changes: 4 additions & 14 deletions src/adminjs.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -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: {},
Expand All @@ -47,11 +47,6 @@ type ActionsMap = {
list: Action<ListActionResponse>;
}

export type LoginOverride<T = Record<string, unknown>> = {
component: FC<T>;
props?: T;
}

export type Adapter = { Database: typeof BaseDatabase; Resource: typeof BaseResource }

/**
Expand Down Expand Up @@ -91,11 +86,6 @@ class AdminJS {
*/
public static VERSION: string

/**
* Login override
*/
private loginOverride?: LoginOverride

/**
* @param {AdminJSOptions} options Options passed to AdminJS
*/
Expand Down Expand Up @@ -196,8 +186,8 @@ class AdminJS {
* the form
* @return {Promise<string>} HTML of the rendered page
*/
async renderLogin({ action, errorMessage }): Promise<string> {
return loginTemplate(this, { action, errorMessage })
async renderLogin(props: LoginTemplateAttributes): Promise<string> {
return loginTemplate(this, props)
}

/**
Expand Down
83 changes: 83 additions & 0 deletions src/backend/utils/auth/base-auth-provider.ts
Original file line number Diff line number Diff line change
@@ -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<T extends AuthenticatePayload> {
componentLoader: ComponentLoader;
authenticate: (payload: T, context?: any) => Promise<CurrentAdmin | null>;
}

export interface LoginHandlerOptions {
data: Record<string, any>;
query?: Record<string, any>;
params?: Record<string, any>;
headers: Record<string, any>;
}

// 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<TContext = any> {
/**
* "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<string, any> {
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<CurrentAdmin | null> {
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<any> {
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<any> {
return Promise.resolve({})
}
}
25 changes: 25 additions & 0 deletions src/backend/utils/auth/default-auth-provider.ts
Original file line number Diff line number Diff line change
@@ -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<DefaultAuthenticatePayload> {}

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)
}
}
2 changes: 2 additions & 0 deletions src/backend/utils/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './base-auth-provider.js'
export * from './default-auth-provider.js'
1 change: 1 addition & 0 deletions src/backend/utils/component-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,6 @@ export class ComponentLoader {
'PropertyDescription',
'PropertyLabel',
'Login',
'AuthenticationBackgroundComponent',
]
}
1 change: 1 addition & 0 deletions src/backend/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 5 additions & 1 deletion src/current-admin.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* @alias CurrentAdmin
* @memberof AdminJS
*/
export type CurrentAdmin = {
export interface CurrentAdmin {
/**
* Admin has one required field which is an email
*/
Expand All @@ -31,6 +31,10 @@ export type CurrentAdmin = {
* Optional ID of theme to use
*/
theme?: string;
/**
* Extra metadata specific to given Auth Provider
*/
_auth?: Record<string, any>;
/**
* Also you can put as many other fields to it as you like.
*/
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/components/app/auth-background-component.tsx
Original file line number Diff line number Diff line change
@@ -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,
}
1 change: 1 addition & 0 deletions src/frontend/components/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 9 additions & 1 deletion src/frontend/components/application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -83,8 +84,15 @@ const App: React.FC = () => {
</Routes>
</Box>
<Modal />
<AuthenticationBackgroundComponent />
</Box>
)
}

export default allowOverride(App, 'Application')
const OverridableApp = allowOverride(App, 'Application')

export {
OverridableApp as default,
OverridableApp as App,
App as OriginalApp,
}
3 changes: 1 addition & 2 deletions src/frontend/hoc/allow-override.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
*
Expand Down
11 changes: 3 additions & 8 deletions src/frontend/login-template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
11 changes: 11 additions & 0 deletions src/frontend/utils/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,17 @@ class ApiClient {
checkResponse(response)
return response
}

async refreshToken(data: Record<string, any>) {
const response = await this.client.request({
url: '/refresh-token',
method: 'POST',
data,
})
checkResponse(response)

return response
}
}

export {
Expand Down
1 change: 1 addition & 0 deletions src/frontend/utils/overridable-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export type OverridableComponent =
| 'PropertyDescription'
| 'PropertyLabel'
| 'Login'
| 'AuthenticationBackgroundComponent'

/**
* Name of the components which can be overridden by ComponentLoader.
Expand Down

0 comments on commit 972c5de

Please sign in to comment.